/projects/workflowsWorkflows
Automations as node canvases: every workflow with its state and last run, the open one drawn as steps joined by arrowed curves on a plane that pans and zooms, an inspector that renames a step, a switch, Run now and the run history.
Installs at /projects/workflows. The page is a server component: workflowRows() reads every db.workflows row — its steps, the edges between them and its runs — and each step's icon, named in the row as a lucide name, is drawn here from NODE_ICONS, the fixed map in data.ts that the entity's WORKFLOW_ICONS keys. One island holds the book. The list gives each workflow its state as a pill and when it last ran, the open one on the chosen fill with a rail; on a phone it is a select. The canvas draws the open workflow on the sheet itself — no dots, no grid — each step a framed button with its icon, its kind, its name and a line of what it does, at the x and y the row gives it, joined by cubic curves that end in an arrowhead, a branch's two ways out labelled yes and no; each step's description says where it leads, so the curves are not the only record of the graph. Drag the plane, or focus it and use the arrow keys (Shift for bigger steps), to pan; − and + zoom between 20% and 150%, Fit shows the whole graph — the longest seeded one whole even in a phone's canvas — and the keys - + and 0 do the same while the plane has focus. It opens fitted, never above 100%, and Tab walks the steps in the order the flow runs, each step it reaches moved into view and clear of the inspector. The pan is the one record of where the plane sits: the viewport clips rather than scrolls, so the browser never shifts it behind the page's back. The plane takes every touch on it (touch-none), so a page scroll cannot start on the canvas and there is no pinch zoom; − + and Fit are the zoom on a phone. Pressing a step opens it in the inspector — laid over the canvas from 1024px, a sheet below — where only its name can change: renameStep refuses an empty name or one too long for its box. The Active switch calls setWorkflowStatus, which refuses to switch on a workflow without exactly one trigger and an action — so a half-built draft stays a draft — and a refusal is said under the strip; Run now calls runWorkflow, which records the run with its outcome at once, so a page closed mid-run never leaves one running, and the history alone plays it as running for the 1.2 s a run takes — not at all when less motion is asked for. flow.ts holds the geometry (the edge curve, the bounds, the fitted view, zooming about a point, what a step sits between) and the rule the actions hold a workflow to. Composes AppShell, PageHeader, Card, StatusBadge, Select, Label, Switch, AsyncButton, Button, Callout, InspectorPanel, Input, DescriptionList and SimpleTable.
Preview
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { WorkflowsView } from "./components/workflows-view"
import { NODE_ICONS, currentUser, now, runSummary, shellNotifications, workflowRows } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The automations. The page is a server component: it reads every workflow
* through `db.workflows`, draws each step's icon here from the fixed map a
* step's icon name points into, and hands one client island the graphs and
* their runs. The island draws the canvas and changes things only through
* the page's actions.
*/
export default function WorkflowsPage() {
const workflows = workflowRows().map((workflow) => ({
...workflow,
nodes: workflow.nodes.map((node) => {
const Icon = NODE_ICONS[node.icon]
return { ...node, glyph: <Icon aria-hidden="true" /> }
}),
}))
return (
<AppShell
nav={NAV}
activeHref={ROUTES.workflows}
user={currentUser()}
notifications={shellNotifications()}
now={now()}
onSignOut={signOut}
>
<PageHeader
title="Workflows"
description="Automations that move orders, payments and leads between Northwind's tools."
meta={runSummary()}
/>
<WorkflowsView workflows={workflows} now={now()} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/projects-workflowsNeeds the @vibra registry in your components.json — set it up once.
Source
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { WorkflowsView } from "./components/workflows-view"
import { NODE_ICONS, currentUser, now, runSummary, shellNotifications, workflowRows } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The automations. The page is a server component: it reads every workflow
* through `db.workflows`, draws each step's icon here from the fixed map a
* step's icon name points into, and hands one client island the graphs and
* their runs. The island draws the canvas and changes things only through
* the page's actions.
*/
export default function WorkflowsPage() {
const workflows = workflowRows().map((workflow) => ({
...workflow,
nodes: workflow.nodes.map((node) => {
const Icon = NODE_ICONS[node.icon]
return { ...node, glyph: <Icon aria-hidden="true" /> }
}),
}))
return (
<AppShell
nav={NAV}
activeHref={ROUTES.workflows}
user={currentUser()}
notifications={shellNotifications()}
now={now()}
onSignOut={signOut}
>
<PageHeader
title="Workflows"
description="Automations that move orders, payments and leads between Northwind's tools."
meta={runSummary()}
/>
<WorkflowsView workflows={workflows} now={now()} />
</AppShell>
)
}/**
* What the workflows page reads: every automation in `db.workflows`, its
* graph and its runs. A step names its icon as a lucide name; NODE_ICONS below
* is the fixed map from that name to the component that draws it, and the
* page draws each step's icon on the server with it. "Now" is REFERENCE_DATE.
*/
import {
Building2Icon,
CalendarClockIcon,
ChartLineIcon,
ClipboardListIcon,
CreditCardIcon,
FileTextIcon,
FlagIcon,
GaugeIcon,
HourglassIcon,
ListTodoIcon,
MailIcon,
MessageCircleIcon,
MessageSquareIcon,
PackageIcon,
ReceiptIcon,
RotateCwIcon,
ShoppingCartIcon,
SplitIcon,
TagIcon,
TicketIcon,
UserCheckIcon,
UserPlusIcon,
UsersIcon,
type LucideIcon,
} from "lucide-react"
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, daysAgo, db, type Member, type Workflow, type WorkflowIcon } from "@/lib/sample-data"
/** Every icon a step may name, drawn: the entity's WORKFLOW_ICONS, one component each. */
export const NODE_ICONS: Record<WorkflowIcon, LucideIcon> = {
"building-2": Building2Icon,
"calendar-clock": CalendarClockIcon,
"chart-line": ChartLineIcon,
"clipboard-list": ClipboardListIcon,
"credit-card": CreditCardIcon,
"file-text": FileTextIcon,
flag: FlagIcon,
gauge: GaugeIcon,
hourglass: HourglassIcon,
"list-todo": ListTodoIcon,
mail: MailIcon,
"message-circle": MessageCircleIcon,
"message-square": MessageSquareIcon,
package: PackageIcon,
receipt: ReceiptIcon,
"rotate-cw": RotateCwIcon,
"shopping-cart": ShoppingCartIcon,
split: SplitIcon,
tag: TagIcon,
ticket: TicketIcon,
"user-check": UserCheckIcon,
"user-plus": UserPlusIcon,
users: UsersIcon,
}
/** Every workflow, in the order the book keeps them: the live ones, the paused one, the drafts. */
export function workflowRows(): Workflow[] {
return db.workflows.all()
}
/** How far back the run history reaches. */
const WINDOW_DAYS = 14
/** "41 runs in the last 14 days, 4 of them failed": the whole book in a line, for the header. */
export function runSummary(): string {
const since = daysAgo(WINDOW_DAYS).getTime()
const runs = db.workflows.all().flatMap((row) => row.runs.filter((run) => run.startedAt.getTime() >= since))
const failed = runs.filter((run) => run.status === "failed").length
return `${runs.length} runs in the last ${WINDOW_DAYS} days${failed ? `, ${failed} of them failed` : ""}`
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** "Now", for the island's relative times — the reference instant, never the clock. */
export function now(): Date {
return REFERENCE_DATE
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, asString, db, type Result, type Workflow, type WorkflowRun } from "@/lib/sample-data"
import { RUN_MS, runnable } from "./flow"
/**
* What the workflows page changes. Server actions, so the page stays a server
* component; `Result`, so a refusal comes back as a sentence the page shows
* beside the control that asked. Every action looks the workflow up again and
* trusts the stored row, never what the canvas says it was.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
/** The longest name a step's box can show on one line. */
const MAX_TITLE = 48
/** How many runs a workflow's history keeps: the newest hundred. */
const MAX_RUNS = 100
const refuse = (code: string, message: string, field?: string): Result<never> => ({
ok: false,
error: field ? { code, message, field } : { code, message },
})
const noWorkflow = (id: string) => refuse("not_found", `No workflow with id "${id}".`)
/** Renames one step. Refuses an empty name, one too long for its box, and a step or workflow that is not there. */
export async function renameStep(
workflowId: string,
nodeId: string,
title: string
): Promise<Result<{ workflowId: string; nodeId: string; title: string }>> {
const workflow = await db.workflows.get(workflowId)
if (!workflow) return noWorkflow(workflowId)
const step = workflow.nodes.find((node) => node.id === nodeId)
if (!step) return refuse("not_found", `${workflow.name} has no step "${nodeId}".`)
const name = asString(title).trim().replace(/\s+/g, " ")
if (!name) return refuse("empty", "A step needs a name.", "title")
if (name.length > MAX_TITLE) return refuse("too_long", `A step's name fits in ${MAX_TITLE} characters.`, "title")
const updated = await db.workflows.update(workflow.id, {
nodes: workflow.nodes.map((node) => (node.id === step.id ? { ...node, title: name } : node)),
})
if (!updated.ok) return updated
return { ok: true, data: { workflowId: workflow.id, nodeId: step.id, title: name } }
}
/**
* Switches a workflow on or off. Switching on refuses a workflow that cannot
* run — no trigger to start it, or nothing for it to do — which is how a
* half-built draft stays a draft; switching off refuses a draft, which was
* never on.
*/
export async function setWorkflowStatus(
id: string,
status: "active" | "paused"
): Promise<Result<{ id: string; status: Workflow["status"] }>> {
const workflow = await db.workflows.get(id)
if (!workflow) return noWorkflow(id)
if (status !== "active" && status !== "paused") return refuse("invalid", "A workflow is switched on or off, nothing else.")
if (status === "active" && !runnable(workflow)) {
return refuse(
"incomplete",
`${workflow.name} needs a trigger and at least one action before it can be switched on.`,
"status"
)
}
if (status === "paused" && workflow.status === "draft") {
return refuse("invalid", `${workflow.name} is a draft — it has never been on, so there is nothing to pause.`, "status")
}
if (workflow.status === status) return { ok: true, data: { id: workflow.id, status } }
const updated = await db.workflows.update(workflow.id, { status })
if (!updated.ok) return updated
return { ok: true, data: { id: workflow.id, status: updated.data.status } }
}
/** The next run number after every run in the book. */
function nextRunId(): string {
const highest = db.workflows
.all()
.flatMap((row) => row.runs)
.reduce((max, run) => Math.max(max, Number(run.id.replace(/\D/g, "")) || 0), 0)
return `run_${String(highest + 1).padStart(5, "0")}`
}
/**
* Runs a workflow now and records how it went: every step went through, in
* the time a run takes. The book has the outcome from the start, so a page
* closed mid-run never leaves one running; the page alone plays the run out
* for that long. Refuses a workflow that cannot run.
*/
export async function runWorkflow(id: string): Promise<Result<{ workflowId: string; run: WorkflowRun }>> {
const workflow = await db.workflows.get(id)
if (!workflow) return noWorkflow(id)
if (!runnable(workflow)) {
return refuse("incomplete", `${workflow.name} needs a trigger and at least one action before it can run.`, "run")
}
const run: WorkflowRun = { id: nextRunId(), startedAt: REFERENCE_DATE, durationMs: RUN_MS, status: "succeeded" }
// The history keeps the newest runs, not every click: a row in a store every
// visitor shares cannot grow by one for each press of Run.
const updated = await db.workflows.update(workflow.id, { runs: [...workflow.runs, run].slice(-MAX_RUNS) })
if (!updated.ok) return updated
return { ok: true, data: { workflowId: workflow.id, run } }
}/**
* The canvas's geometry and the one rule a workflow is held to, in plain
* functions: where an edge runs, how big the graph is, what view fits it,
* what a step sits between, and whether a workflow can run at all. The island
* draws with the geometry; the actions hold a workflow to `runnable`, and the
* switch shows what they answer.
*/
import type * as React from "react"
import type { Workflow, WorkflowNode, WorkflowRun } from "@/lib/sample-data"
/** A step's box on the plane, in pixels at 100%: the grid the entity lays steps on leaves room for this. */
export const NODE_WIDTH = 216
export const NODE_HEIGHT = 76
/**
* The zoom's range, and one press of − or +. The floor is low enough that Fit
* shows the longest seeded graph whole in a phone's 358px canvas (23%); the
* steps are then a map of the flow, not something to read.
*/
export const ZOOM_MIN = 0.2
export const ZOOM_MAX = 1.5
export const ZOOM_STEP = 0.1
/** How far one arrow key moves the plane, and how far with Shift held. */
export const PAN_STEP = 48
export const PAN_STEP_LARGE = 192
/** How long a run started from the page takes — and so how long the history shows it running. */
export const RUN_MS = 1200
export const KIND_LABELS: Record<WorkflowNode["kind"], string> = {
trigger: "Trigger",
action: "Action",
branch: "Branch",
delay: "Delay",
}
export const STATUS_LABELS: Record<Workflow["status"], string> = { active: "Active", paused: "Paused", draft: "Draft" }
export const RUN_LABELS: Record<WorkflowRun["status"], string> = {
succeeded: "Succeeded",
failed: "Failed",
running: "Running",
}
/**
* What a step sits between: the titles of the steps that lead into it, and
* each way out of it with the branch's answer, if it has one. The step's
* description on the canvas and the inspector both say it from here.
*/
export function neighbours(
workflow: Pick<Workflow, "nodes" | "edges">,
id: string
): { before: string[]; after: { title: string; label?: string }[] } {
const titles = new Map(workflow.nodes.map((node) => [node.id, node.title]))
return {
before: workflow.edges.filter((edge) => edge.to === id).map((edge) => titles.get(edge.from) ?? edge.from),
after: workflow.edges
.filter((edge) => edge.from === id)
.map((edge) => ({ title: titles.get(edge.to) ?? edge.to, ...(edge.label ? { label: edge.label } : {}) })),
}
}
/** A step as the island draws it: the row, with its icon already drawn on the server. */
export type Step = WorkflowNode & { glyph: React.ReactNode }
/** A workflow as the island holds it. */
export type Flow = Omit<Workflow, "nodes"> & { nodes: Step[] }
/** A workflow can run with exactly one trigger to start it and at least one action to do. */
export function runnable(workflow: Pick<Workflow, "nodes">): boolean {
const triggers = workflow.nodes.filter((node) => node.kind === "trigger").length
return triggers === 1 && workflow.nodes.some((node) => node.kind === "action")
}
export type Point = { x: number; y: number }
export type Size = { width: number; height: number }
/** The plane the steps sit on: from the origin to the far corner of the furthest step. */
export function boundsOf(nodes: readonly Point[]): Size {
return {
width: Math.max(0, ...nodes.map((node) => node.x)) + NODE_WIDTH,
height: Math.max(0, ...nodes.map((node) => node.y)) + NODE_HEIGHT,
}
}
/**
* The curve from one step's right edge to the next step's left edge, both at
* mid-height: a cubic whose handles run level out of each end, so a branch
* leaves and arrives square. `mid` is the curve's halfway point, for a label.
*/
export function edgePath(from: Point, to: Point): { d: string; mid: Point } {
const x1 = from.x + NODE_WIDTH
const y1 = from.y + NODE_HEIGHT / 2
const x2 = to.x
const y2 = to.y + NODE_HEIGHT / 2
const reach = Math.max(24, (x2 - x1) / 2)
return {
d: `M ${x1} ${y1} C ${x1 + reach} ${y1}, ${x2 - reach} ${y2}, ${x2} ${y2}`,
mid: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 },
}
}
/** Where the plane sits in the viewport, and how large it is drawn. */
export type View = { x: number; y: number; zoom: number }
/** A zoom inside the range, to the hundredth, so the readout is a whole percentage. */
export function clampZoom(zoom: number): number {
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom)) * 100) / 100
}
/**
* One press of zoom in `direction`, onto a multiple of ZOOM_STEP: from a
* fitted 99% the next press in is 100% and the next out 90%, so the readout
* lands on round figures after a single press.
*/
export function stepZoom(zoom: number, direction: 1 | -1): number {
const steps = zoom / ZOOM_STEP
const from = direction === 1 ? Math.floor(steps + 1e-6) : Math.ceil(steps - 1e-6)
return clampZoom((from + direction) * ZOOM_STEP)
}
/**
* The view that shows the whole graph with a margin, centred, never larger
* than `maxZoom`. The zoom rounds down, so the graph always fits inside the
* margin. A graph too long to fit even at ZOOM_MIN is shown from its start —
* the trigger's side, where the flow begins — rather than cropped at both ends.
*/
export function fitView(bounds: Size, viewport: Size, maxZoom = ZOOM_MAX, margin = 24): View {
const room = { width: viewport.width - margin * 2, height: viewport.height - margin * 2 }
const scale = Math.min(maxZoom, room.width / bounds.width, room.height / bounds.height)
const zoom = clampZoom(Math.floor(scale * 100 + 1e-6) / 100)
const place = (length: number, space: number, side: number) =>
length * zoom <= space - margin * 2 + 1e-6 ? Math.round((space - length * zoom) / 2) : side
return {
x: place(bounds.width, viewport.width, margin),
y: place(bounds.height, viewport.height, margin),
zoom,
}
}
/** The smallest zoom a workflow opens at: below it a step's name is too small to read. */
export const OPENING_ZOOM_MIN = 0.8
/**
* The view a workflow opens in: fitted and centred when the whole graph reads
* at 80% or more (and never above 100%); a longer one opens at 80% from its
* trigger, the rest a pan or a Fit away.
*/
export function openingView(bounds: Size, viewport: Size, margin = 24): View {
const fitted = fitView(bounds, viewport, 1, margin)
if (fitted.zoom >= OPENING_ZOOM_MIN) return fitted
return {
x: margin,
y: Math.round((viewport.height - bounds.height * OPENING_ZOOM_MIN) / 2),
zoom: OPENING_ZOOM_MIN,
}
}
/** The view zoomed to `zoom` about a point of the viewport, which stays where it is on screen. */
export function zoomAround(view: View, zoom: number, center: Point): View {
const next = clampZoom(zoom)
const ratio = next / view.zoom
return {
x: Math.round(center.x - (center.x - view.x) * ratio),
y: Math.round(center.y - (center.y - view.y) * ratio),
zoom: next,
}
}"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { boundsOf, edgePath, type Flow } from "../flow"
/**
* The connections, drawn under the steps: a curve from each step to the next
* that ends in an arrowhead, in the muted ink — the ink itself for the ones
* that touch the picked step — and a small "yes" or "no" chip where a branch
* splits. Decoration to a screen reader: each step says where it leads.
*/
export function CanvasEdges({ flow, stepId }: { flow: Flow; stepId: string | null }) {
const id = React.useId()
const at = new Map(flow.nodes.map((node) => [node.id, node]))
const { width, height } = boundsOf(flow.nodes)
const edges = flow.edges.flatMap((edge) => {
const from = at.get(edge.from)
const to = at.get(edge.to)
if (!from || !to) return []
return [{ ...edge, ...edgePath(from, to), live: stepId !== null && (edge.from === stepId || edge.to === stepId) }]
})
return (
<>
<svg
aria-hidden="true"
width={width}
height={height}
className="pointer-events-none absolute top-0 left-0 overflow-visible text-muted-foreground"
>
<defs>
{(["quiet", "live"] as const).map((tone) => (
<marker
key={tone}
id={`${id}-${tone}`}
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
className={tone === "live" ? "text-foreground" : undefined}
>
<path d="M 0 1 L 9 5 L 0 9 z" fill="currentColor" />
</marker>
))}
</defs>
{edges.map((edge) => (
<path
key={`${edge.from}>${edge.to}`}
data-slot="canvas-edge"
data-from={edge.from}
data-to={edge.to}
data-label={edge.label}
data-live={edge.live || undefined}
d={edge.d}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
markerEnd={`url(#${id}-${edge.live ? "live" : "quiet"})`}
className={cn(edge.live && "text-foreground")}
/>
))}
</svg>
{edges
.filter((edge) => edge.label)
.map((edge) => (
<span
key={`${edge.from}>${edge.to}`}
aria-hidden="true"
data-slot="canvas-edge-label"
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-card px-1.5 text-2xs leading-4 font-medium text-muted-foreground ring-1 ring-border"
style={{ left: edge.mid.x, top: edge.mid.y }}
>
{edge.label}
</span>
))}
</>
)
}"use client"
import * as React from "react"
import { formatDuration, formatRelative } from "@/lib/format"
import type { WorkflowRun } from "@/lib/sample-data"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SimpleTable } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { RUN_LABELS, type Flow } from "../flow"
// Pinned to UTC, so a run reads the same wherever the page is opened.
const STARTED = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZone: "UTC",
})
const RUN_TONES = { succeeded: "success", failed: "danger", running: "info" } as const
/** Past a week, "how long ago" is only the date again, which the column already prints. */
const WEEK_MS = 7 * 86_400_000
/** "1.3s" under a minute — a run is seconds of work, and whole seconds would call most of them "1s". */
function took(ms: number): string {
return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms)
}
/** "13 succeeded · 2 failed", and a running one when there is one. */
function tally(runs: WorkflowRun[]): string {
const count = (status: WorkflowRun["status"]) => runs.filter((run) => run.status === status).length
return (["succeeded", "failed", "running"] as const)
.filter((status) => count(status) > 0)
.map((status) => `${count(status)} ${RUN_LABELS[status].toLowerCase()}`)
.join(" · ")
}
/**
* The open workflow's runs, newest first: the run's id, when it started,
* how long it took and how it ended. A run started from the page is in the
* book with its outcome from the start; `playing` names the ones the page
* still shows running while they play out.
*/
export function RunHistory({ flow, now, playing }: { flow: Flow; now: Date; playing: ReadonlySet<string> }) {
const titleId = React.useId()
const shown = flow.runs.map((run): WorkflowRun => (playing.has(run.id) ? { ...run, status: "running" } : run))
const runs = [...shown].reverse()
return (
<Card role="region" aria-labelledby={titleId}>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Run history</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{runs.length ? `${runs.length} ${runs.length === 1 ? "run" : "runs"}, newest first · ${tally(shown)}` : "No runs yet"}
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<SimpleTable
className="rounded-none border-0"
rows={runs}
rowKey="id"
emptyMessage={flow.status === "draft" ? "A draft has never run. Switch it on, or run it now once it has a trigger." : "Nothing has run yet."}
columns={[
{
key: "id",
header: "Run",
cell: (run) => <span className="font-mono text-xs">{run.id}</span>,
},
{
key: "startedAt",
header: "Started",
cell: (run) => (
<time dateTime={run.startedAt.toISOString()} className="flex flex-col tabular-nums sm:flex-row sm:gap-2">
<span>{STARTED.format(run.startedAt)}</span>
{now.getTime() - run.startedAt.getTime() < WEEK_MS ? (
<span className="text-xs text-muted-foreground sm:text-sm">{formatRelative(run.startedAt, now)}</span>
) : null}
</time>
),
},
{
key: "durationMs",
header: "Duration",
align: "right",
// A phone has no room for the column; the time rides under the status instead.
className: "max-sm:hidden",
cell: (run) => (run.status === "running" ? "—" : took(run.durationMs)),
},
{
key: "status",
header: "Status",
cell: (run) => (
<span className="flex flex-col items-start gap-0.5">
<StatusBadge
status={run.status}
label={RUN_LABELS[run.status]}
variant={RUN_TONES[run.status]}
pulse={run.status === "running"}
/>
{run.status === "running" ? null : (
<span className="text-xs text-muted-foreground tabular-nums sm:hidden">
{took(run.durationMs)}
</span>
)}
</span>
),
},
]}
/>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import type { Result } from "@/lib/sample-data"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { DescriptionList } from "@/components/ui/description-list"
import { InspectorPanel } from "@/components/ui/inspector-panel"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { KIND_LABELS, neighbours, type Flow, type Step } from "../flow"
/** The step's name, the one thing about it the page lets you change. */
function RenameForm({
step,
onRename,
}: {
step: Step
onRename: (nodeId: string, title: string) => Promise<Result<{ title: string }>>
}) {
const inputId = React.useId()
const alertId = React.useId()
const [draft, setDraft] = React.useState(step.title)
const [pending, setPending] = React.useState(false)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setPending(true)
setNotice("")
setRefusal("")
const result = await onRename(step.id, draft)
setPending(false)
if (!result.ok) return setRefusal(result.error.message)
setDraft(result.data.title)
setNotice(`Renamed to “${result.data.title}”.`)
}
return (
<form data-slot="step-rename" className="flex flex-col gap-2" onSubmit={submit}>
<Label htmlFor={inputId}>Name</Label>
<div className="flex gap-2">
<Input
id={inputId}
value={draft}
autoComplete="off"
onChange={(event) => setDraft(event.target.value)}
aria-invalid={refusal ? true : undefined}
aria-describedby={refusal ? alertId : undefined}
/>
<Button type="submit" variant="outline" disabled={pending} focusableWhenDisabled>
Rename
</Button>
</div>
{refusal ? (
<Callout id={alertId} variant="danger" role="alert">
{refusal}
</Callout>
) : null}
<p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
{notice}
</p>
</form>
)
}
export type StepInspectorProps = {
flow: Flow
step: Step | null
mode: "inline" | "overlay"
onClose: () => void
onRename: (nodeId: string, title: string) => Promise<Result<{ title: string }>>
}
/**
* The picked step, beside the canvas — or over it as a sheet on a narrow
* page. Its name can be changed here; what kind of step it is, what it does
* and what it sits between are read-only, because the graph is the
* workflow's and the canvas only draws it.
*/
export function StepInspector({ flow, step, mode, onClose, onRename }: StepInspectorProps) {
// The panel outlives the pick by one animation, so the last step stays drawn while it closes.
const [last, setLast] = React.useState(step)
if (step && step !== last) setLast(step)
const shown = step ?? (last && flow.nodes.find((node) => node.id === last.id)) ?? null
if (!shown) return null
const { before, after: ways } = neighbours(flow, shown.id)
const after = ways.map(({ title, label }) => `${title}${label ? ` (${label})` : ""}`)
return (
<InspectorPanel
open={step !== null}
onOpenChange={(open) => {
if (!open) onClose()
}}
mode={mode}
width="sm"
title={shown.title}
description={`${KIND_LABELS[shown.kind]} · ${flow.name}`}
className="min-h-full"
>
<div className="flex flex-col gap-5">
<RenameForm key={shown.id} step={shown} onRename={onRename} />
<DescriptionList
size="sm"
orientation="vertical"
items={[
{ term: "Step", description: KIND_LABELS[shown.kind] },
{ term: "What it does", description: shown.summary },
{
term: "Comes after",
description: before.length ? before.join(", ") : "Nothing — it starts the workflow",
},
{
term: "Goes to",
description: after.length ? after.join(", ") : "Nothing — the workflow ends here",
},
]}
/>
</div>
</InspectorPanel>
)
}"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { KIND_LABELS, NODE_HEIGHT, NODE_WIDTH, neighbours, type Flow, type Step } from "../flow"
/** "Leads to Post to #big-orders if yes, Post to #orders if no." — where a step goes, for a screen reader. */
export function leadsTo(flow: Flow, step: Step): string {
const next = neighbours(flow, step.id).after.map(({ title, label }) => `${title}${label ? ` if ${label}` : ""}`)
return next.length ? `Leads to ${next.join(", ")}.` : "Ends the workflow."
}
export type StepNodeProps = {
flow: Flow
step: Step
selected: boolean
onSelect: () => void
/** Focus landed on the step: the canvas moves it clear of what covers it. */
onFocus?: () => void
}
/**
* One step, drawn where the workflow puts it: a framed card with its icon,
* what kind of step it is, its name and a line of what it does. It is a
* button — press it to open the step in the inspector, again to close it —
* and the picked one wears the chosen fill rather than an outline.
*/
export function StepNode({ flow, step, selected, onSelect, onFocus }: StepNodeProps) {
const describedBy = React.useId()
return (
<button
type="button"
data-slot="canvas-step"
data-kind={step.kind}
data-selected={selected || undefined}
aria-label={step.title}
aria-pressed={selected}
aria-describedby={describedBy}
onClick={onSelect}
onFocus={onFocus}
className={cn(
"absolute flex items-start gap-3 rounded-lg bg-card p-3 text-left ring-1 ring-border transition-colors duration-(--duration-fast) ease-(--ease-standard) focus-ring",
"hover:bg-accent/60 data-selected:bg-brand-muted data-selected:hover:bg-brand-muted"
)}
style={{ left: step.x, top: step.y, width: NODE_WIDTH, height: NODE_HEIGHT }}
>
<span
aria-hidden="true"
className={cn(
"grid size-8 shrink-0 place-items-center rounded-md [&_svg]:size-4",
step.kind === "trigger" ? "bg-foreground text-background" : "bg-secondary text-foreground"
)}
>
{step.glyph}
</span>
<span className="flex min-w-0 flex-col">
<span className="text-xs text-muted-foreground">{KIND_LABELS[step.kind]}</span>
<span className="truncate text-sm font-semibold text-foreground">{step.title}</span>
<span className="truncate text-xs text-muted-foreground">{step.summary}</span>
</span>
<span id={describedBy} className="sr-only">
{`${KIND_LABELS[step.kind]}. ${step.summary}. ${leadsTo(flow, step)}`}
</span>
</button>
)
}"use client"
import * as React from "react"
import { MinusIcon, PlusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
NODE_HEIGHT,
NODE_WIDTH,
PAN_STEP,
PAN_STEP_LARGE,
ZOOM_MAX,
ZOOM_MIN,
boundsOf,
fitView,
openingView,
stepZoom,
zoomAround,
type Flow,
type Step,
type View,
} from "../flow"
import { CanvasEdges } from "./canvas-edges"
import { StepNode } from "./step-node"
/** Where the plane opens before the viewport has been measured: at 100%, a margin in. */
const START: View = { x: 24, y: 24, zoom: 1 }
/** How close to the viewport's edge a step revealed by the inspector may sit. */
const EDGE = 16
export type WorkflowCanvasProps = {
flow: Flow
stepId: string | null
onStep: (id: string | null) => void
/** What the page's last action did, said in the line under the plane. */
notice: string
/** How much of the plane's right side the inspector covers once a step is open, so the step is moved clear of it. */
covered?: number
/** Laid over the plane's right side: the inspector, on a wide page. */
children?: React.ReactNode
}
/**
* The open workflow as a graph on a plane that pans and zooms. The plane is
* the sheet itself — no dots, no grid — and the steps sit on it where the
* workflow puts them, joined by curves that end in an arrowhead. Drag the
* plane, or focus it and use the arrow keys (Shift for bigger steps), to
* pan; − and + zoom between 20% and 150% and Fit shows the whole graph, from
* the buttons or the keys. It opens fitted, between 80% and 100% — a long
* graph at 80% from its trigger. A picked step, and a step reached with Tab,
* is moved into view clear of the inspector. The pan is the one truth of
* where the plane sits: the viewport clips and never scrolls, so the browser
* cannot shift the plane behind it.
*/
export function WorkflowCanvas({ flow, stepId, onStep, notice, covered = 0, children }: WorkflowCanvasProps) {
const viewportRef = React.useRef<HTMLDivElement>(null)
const drag = React.useRef<{ pointer: number; fromX: number; fromY: number; x: number; y: number } | null>(null)
const [view, setView] = React.useState<View>(START)
const [gliding, setGliding] = React.useState(false)
const [dragging, setDragging] = React.useState(false)
const hintId = React.useId()
const bounds = React.useMemo(() => boundsOf(flow.nodes), [flow.nodes])
// Left to right, then top to bottom: the order the flow runs in is the order Tab walks it.
const steps = React.useMemo(() => [...flow.nodes].sort((a, b) => a.x - b.x || a.y - b.y), [flow.nodes])
const measure = React.useCallback(() => {
const box = viewportRef.current?.getBoundingClientRect()
return box && box.width > 0 && box.height > 0 ? { width: box.width, height: box.height } : null
}, [])
// Placed once, the first time the viewport has a size: the whole graph
// when it reads, the start of a long one when it would not. Only once — a
// rename or a resize afterwards leaves the reader's pan and zoom alone.
const placed = React.useRef(false)
React.useEffect(() => {
const node = viewportRef.current
if (!node || typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
if (placed.current || width <= 0 || height <= 0) return
placed.current = true
setView(openingView(bounds, { width, height }))
})
observer.observe(node)
return () => observer.disconnect()
}, [bounds])
function zoomTo(direction: 1 | -1) {
const size = measure()
setGliding(true)
setView((current) =>
zoomAround(current, stepZoom(current.zoom, direction), size ? { x: size.width / 2, y: size.height / 2 } : { x: 0, y: 0 })
)
}
function fit() {
const size = measure()
setGliding(true)
setView(size ? fitView(bounds, size) : START)
}
function panBy(dx: number, dy: number) {
setGliding(true)
setView((current) => ({ ...current, x: current.x + dx, y: current.y + dy }))
}
/** Moves the plane just enough that `step` sits inside the part of the viewport `cover` leaves. */
function reveal(step: Step, cover: number) {
const size = measure()
if (!size) return
setGliding(true)
setView((current) => {
const left = current.x + step.x * current.zoom
const top = current.y + step.y * current.zoom
const right = left + NODE_WIDTH * current.zoom
const bottom = top + NODE_HEIGHT * current.zoom
const room = size.width - cover - EDGE
let dx = right > room ? room - right : 0
if (left + dx < EDGE) dx = EDGE - left
let dy = bottom > size.height - EDGE ? size.height - EDGE - bottom : 0
if (top + dy < EDGE) dy = EDGE - top
return dx || dy ? { ...current, x: current.x + dx, y: current.y + dy } : current
})
}
function select(step: Step) {
if (step.id === stepId) return onStep(null)
onStep(step.id)
reveal(step, covered)
}
// A step focused by a press is about to be picked, and the pick moves it;
// one reached from the keyboard is moved clear as focus lands on it, of the
// inspector too while a step is open.
const pressing = React.useRef(false)
function focusStep(step: Step) {
if (!pressing.current) reveal(step, stepId ? covered : 0)
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
// Keys belong to the plane only while it has focus itself, not a step on it.
if (event.target !== event.currentTarget) return
const step = event.shiftKey ? PAN_STEP_LARGE : PAN_STEP
const keys: Record<string, () => void> = {
ArrowLeft: () => panBy(step, 0),
ArrowRight: () => panBy(-step, 0),
ArrowUp: () => panBy(0, step),
ArrowDown: () => panBy(0, -step),
"+": () => zoomTo(1),
"=": () => zoomTo(1),
"-": () => zoomTo(-1),
"0": fit,
}
const act = keys[event.key]
if (!act) return
event.preventDefault()
act()
}
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
if (event.button !== 0 || (event.target as Element).closest("button")) return
event.currentTarget.setPointerCapture?.(event.pointerId)
drag.current = { pointer: event.pointerId, fromX: event.clientX, fromY: event.clientY, x: view.x, y: view.y }
setGliding(false)
setDragging(true)
}
function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {
const start = drag.current
if (!start || start.pointer !== event.pointerId) return
setView((current) => ({ ...current, x: start.x + event.clientX - start.fromX, y: start.y + event.clientY - start.fromY }))
}
function endDrag(event: React.PointerEvent<HTMLDivElement>) {
if (drag.current?.pointer !== event.pointerId) return
drag.current = null
setDragging(false)
}
return (
<div data-slot="workflow-canvas-frame" className="flex min-w-0 flex-1 flex-col">
<div className="relative">
<div
ref={viewportRef}
role="group"
aria-roledescription="canvas"
aria-label={`${flow.name}, ${flow.nodes.length} steps`}
aria-describedby={hintId}
tabIndex={0}
data-slot="workflow-canvas"
data-zoom={view.zoom}
data-pan-x={Math.round(view.x)}
data-pan-y={Math.round(view.y)}
data-dragging={dragging || undefined}
onKeyDown={onKeyDown}
onPointerDownCapture={() => {
pressing.current = true
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerUpCapture={() => {
pressing.current = false
}}
onPointerCancel={(event) => {
pressing.current = false
endDrag(event)
}}
className="relative h-72 cursor-grab touch-none overflow-clip rounded-t-lg select-none focus-ring-inset data-dragging:cursor-grabbing sm:h-96"
>
<div
data-slot="workflow-plane"
className={cn(
"absolute top-0 left-0 origin-top-left",
gliding && "transition-transform duration-(--duration-base) ease-(--ease-standard)"
)}
style={{ width: bounds.width, height: bounds.height, transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}
>
<CanvasEdges flow={flow} stepId={stepId} />
{steps.map((step) => (
<StepNode
key={step.id}
flow={flow}
step={step}
selected={step.id === stepId}
onSelect={() => select(step)}
onFocus={() => focusStep(step)}
/>
))}
</div>
</div>
{children ? <div className="absolute inset-y-0 right-0 flex">{children}</div> : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-t border-border px-3 py-2">
<div role="toolbar" aria-label="Zoom" className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
aria-label="Zoom out"
aria-keyshortcuts="-"
// Focusable at the end of the range, so the press that reaches it keeps focus.
disabled={view.zoom <= ZOOM_MIN}
focusableWhenDisabled
onClick={() => zoomTo(-1)}
>
<MinusIcon />
</Button>
<output aria-label="Zoom level" className="min-w-12 text-center text-xs font-medium tabular-nums">
{`${Math.round(view.zoom * 100)}%`}
</output>
<Button
variant="outline"
size="icon-sm"
aria-label="Zoom in"
aria-keyshortcuts="+"
disabled={view.zoom >= ZOOM_MAX}
focusableWhenDisabled
onClick={() => zoomTo(1)}
>
<PlusIcon />
</Button>
<Button variant="outline" size="sm" aria-keyshortcuts="0" className="ml-1" onClick={fit}>
Fit
</Button>
</div>
<div className="min-w-0 text-xs text-muted-foreground sm:text-right">
{/* Always mounted, so it is heard when it fills; while it has nothing
to say, the hint for the plane takes its place on screen. */}
<p data-slot="workflow-notice" role="status" aria-live="polite" className={notice ? undefined : "sr-only"}>
{notice}
</p>
<p id={hintId} className={notice ? "sr-only" : undefined}>
Drag the plane, or focus it and use the arrow keys, to pan; − and + zoom, and 0 fits the graph.
</p>
</div>
</div>
</div>
)
}"use client"
import * as React from "react"
import { PlayIcon } from "lucide-react"
import type { Result } from "@/lib/sample-data"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { StatusBadge } from "@/components/ui/status-badge"
import { Switch } from "@/components/ui/switch"
import { STATUS_LABELS, type Flow } from "../flow"
import { StepInspector } from "./step-inspector"
import { WorkflowCanvas } from "./workflow-canvas"
import { lastRun } from "./workflow-list"
/** The inspector's width over the plane: InspectorPanel's "sm", 20rem. */
const INSPECTOR_WIDTH = 320
export type WorkflowCardProps = {
flow: Flow
stepId: string | null
onStep: (id: string | null) => void
/** Beside the canvas on a wide page, over it as a sheet on a narrow one. */
inspector: "inline" | "overlay"
onToggle: (on: boolean) => Promise<void>
onRun: () => Promise<void>
onRename: (nodeId: string, title: string) => Promise<Result<{ title: string }>>
notice: string
refusal: string
now: Date
}
/**
* The open workflow: its name, whether it is on and a way to run it now in
* the strip; the canvas in the sheet, with the inspector for the picked step
* beside it; and what the last action did in the line under the plane. A
* refusal is said under the strip, by the switch and the button that asked.
*/
export function WorkflowCard({
flow,
stepId,
onStep,
inspector,
onToggle,
onRun,
onRename,
notice,
refusal,
now,
}: WorkflowCardProps) {
const titleId = React.useId()
const switchId = React.useId()
const step = flow.nodes.find((node) => node.id === stepId) ?? null
const steps = flow.nodes.length
// Laid over the plane's right side on a wide page, so opening it never
// squeezes the graph; a sheet over the page on a narrow one.
const panel = (
<StepInspector flow={flow} step={step} mode={inspector} onClose={() => onStep(null)} onRename={onRename} />
)
return (
<Card role="region" aria-labelledby={titleId}>
<CardHeader className="max-sm:has-data-[slot=card-action]:grid-cols-1">
<CardTitle className="flex min-w-0 items-center gap-2">
<h2 id={titleId} className="truncate">
{flow.name}
</h2>
<StatusBadge status={flow.status} label={STATUS_LABELS[flow.status]} size="sm" />
</CardTitle>
<CardDescription className="tabular-nums">{`${steps} steps · ${lastRun(flow, now)}`}</CardDescription>
<CardAction className="flex items-center gap-3 max-sm:col-start-1 max-sm:row-span-1 max-sm:row-start-3 max-sm:justify-self-start">
<span className="flex items-center gap-2">
<Switch
id={switchId}
checked={flow.status === "active"}
onCheckedChange={(checked) => void onToggle(checked)}
/>
<Label htmlFor={switchId} className="text-sm">
Active
</Label>
</span>
<AsyncButton variant="outline" size="sm" onClick={onRun}>
<PlayIcon data-icon="inline-start" />
Run now
</AsyncButton>
</CardAction>
</CardHeader>
{refusal ? (
<div className="px-(--card-spacing) pb-2">
<Callout variant="danger" role="alert">
{refusal}
</Callout>
</div>
) : null}
<CardContent className="flex p-0">
<WorkflowCanvas
flow={flow}
stepId={stepId}
onStep={onStep}
notice={notice}
covered={inspector === "inline" ? INSPECTOR_WIDTH : 0}
>
{inspector === "inline" ? panel : null}
</WorkflowCanvas>
{inspector === "overlay" ? panel : null}
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { formatRelative } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { StatusBadge } from "@/components/ui/status-badge"
import { STATUS_LABELS, type Flow } from "../flow"
/** "Last run 2h ago", or "Never run" for a workflow with no runs. */
export function lastRun(flow: Flow, now: Date): string {
const latest = flow.runs[flow.runs.length - 1]
return latest ? `Last run ${formatRelative(latest.startedAt, now)}` : "Never run"
}
/** "5 active · 1 paused · 2 drafts". */
function tally(flows: Flow[]): string {
const count = (status: Flow["status"]) => flows.filter((flow) => flow.status === status).length
const drafts = count("draft")
return `${count("active")} active · ${count("paused")} paused · ${drafts} ${drafts === 1 ? "draft" : "drafts"}`
}
export type WorkflowListProps = {
flows: Flow[]
openId: string
now: Date
onOpen: (id: string) => void
className?: string
}
/**
* Every workflow, beside the canvas from 1280px: its name, whether it is on
* and when it last ran. The open one wears the chosen fill, a rail and the
* heavier weight — three cues, never the colour alone.
*/
export function WorkflowList({ flows, openId, now, onOpen, className }: WorkflowListProps) {
const titleId = React.useId()
return (
<Card role="region" aria-labelledby={titleId} className={cn("xl:sticky xl:top-20", className)}>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Workflows</h2>
</CardTitle>
<CardDescription className="tabular-nums">{tally(flows)}</CardDescription>
</CardHeader>
<CardContent className="p-1.5">
<ul className="flex flex-col gap-0.5">
{flows.map((flow) => {
const open = flow.id === openId
return (
<li key={flow.id}>
<button
type="button"
aria-current={open ? "true" : undefined}
onClick={() => onOpen(flow.id)}
className={cn(
"flex w-full flex-col items-start gap-1 rounded-md px-2.5 py-2 text-left transition-colors duration-(--duration-fast) ease-(--ease-standard) focus-ring hover:bg-muted/60",
open && "bg-brand-muted font-medium shadow-[inset_2px_0_0_var(--brand)] hover:bg-brand-muted"
)}
>
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate text-sm">{flow.name}</span>
<StatusBadge status={flow.status} label={STATUS_LABELS[flow.status]} size="sm" />
</span>
<span className="text-xs font-normal text-muted-foreground tabular-nums">{lastRun(flow, now)}</span>
</button>
</li>
)
})}
</ul>
</CardContent>
</Card>
)
}
/** The same choice on a phone, where there is no room for a list beside the canvas. */
export function WorkflowPicker({ flows, openId, onOpen, className }: Omit<WorkflowListProps, "now">) {
const labelId = React.useId()
const items = flows.map((flow) => ({ value: flow.id, label: flow.name }))
return (
<div data-slot="workflow-picker" className={cn("flex flex-col gap-1.5", className)}>
<Label id={labelId} className="text-xs text-muted-foreground">
Workflow
</Label>
<Select value={openId} onValueChange={(next) => next && onOpen(next as string)} items={items}>
<SelectTrigger aria-labelledby={labelId} className="h-9 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{flows.map((flow) => (
<SelectItem key={flow.id} value={flow.id}>
<span className="truncate">{flow.name}</span>
<span className="text-muted-foreground">{STATUS_LABELS[flow.status]}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}"use client"
import * as React from "react"
import { useMediaQuery } from "@/hooks/use-media-query"
import { prefersReducedMotion } from "@/hooks/use-reduced-motion"
import type { Result, WorkflowRun } from "@/lib/sample-data"
import { renameStep, runWorkflow, setWorkflowStatus } from "../actions"
import { RUN_MS, type Flow } from "../flow"
import { RunHistory } from "./run-history"
import { WorkflowCard } from "./workflow-card"
import { WorkflowList, WorkflowPicker } from "./workflow-list"
/**
* The workflows page's one island. It holds the book as the actions last left
* it, which workflow is open and which of its steps is picked; the list, the
* canvas, the inspector and the run history all read from here, so a rename,
* a switch or a run shows everywhere at once. The list stands beside the
* canvas from 1280px — below that it is a select above it, so the canvas
* keeps the width — and the inspector lies over the canvas from 1024px, a
* sheet below.
*/
export function WorkflowsView({ workflows, now }: { workflows: Flow[]; now: Date }) {
const [flows, setFlows] = React.useState(workflows)
const [openId, setOpenId] = React.useState(workflows[0]?.id ?? "")
const [stepId, setStepId] = React.useState<string | null>(null)
const [notice, setNotice] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const wide = useMediaQuery("(min-width: 64rem)")
const flow = flows.find((candidate) => candidate.id === openId) ?? flows[0]
// A run started here is in the book with its outcome already; the page
// plays it out as running for as long as a run takes, then shows how it
// ended. Leaving the page stops the clocks, and the book needs nothing.
const [playing, setPlaying] = React.useState<ReadonlySet<string>>(() => new Set())
const clocks = React.useRef(new Set<ReturnType<typeof setTimeout>>())
React.useEffect(() => {
const pending = clocks.current
return () => {
for (const clock of pending) clearTimeout(clock)
pending.clear()
}
}, [])
function patch(workflowId: string, change: (current: Flow) => Flow) {
setFlows((current) => current.map((candidate) => (candidate.id === workflowId ? change(candidate) : candidate)))
}
function putRun(workflowId: string, run: WorkflowRun) {
patch(workflowId, (current) => ({
...current,
runs: current.runs.some((candidate) => candidate.id === run.id)
? current.runs.map((candidate) => (candidate.id === run.id ? run : candidate))
: [...current.runs, run],
}))
}
function open(id: string) {
setOpenId(id)
setStepId(null)
setNotice("")
setRefusal("")
}
async function toggle(on: boolean) {
setNotice("")
setRefusal("")
const result = await setWorkflowStatus(flow.id, on ? "active" : "paused")
if (!result.ok) return setRefusal(result.error.message)
patch(flow.id, (current) => ({ ...current, status: result.data.status }))
setNotice(
result.data.status === "active"
? `${flow.name} is on — it runs every time its trigger fires.`
: `${flow.name} is paused — nothing runs until it is switched back on.`
)
}
async function run() {
setNotice("")
setRefusal("")
const name = flow.name
const result = await runWorkflow(flow.id)
if (!result.ok) return setRefusal(result.error.message)
const { workflowId, run: done } = result.data
putRun(workflowId, done)
const ended = `${done.id} of ${name} ${done.status === "failed" ? "failed" : "succeeded"}.`
// Asked for less motion, the history goes straight to how it ended.
if (prefersReducedMotion()) return setNotice(ended)
setNotice(`${done.id} started.`)
setPlaying((current) => new Set(current).add(done.id))
const clock = setTimeout(() => {
clocks.current.delete(clock)
setPlaying((current) => {
const next = new Set(current)
next.delete(done.id)
return next
})
setNotice(ended)
}, RUN_MS)
clocks.current.add(clock)
}
async function rename(nodeId: string, title: string): Promise<Result<{ title: string }>> {
const result = await renameStep(flow.id, nodeId, title)
if (!result.ok) return result
patch(result.data.workflowId, (current) => ({
...current,
nodes: current.nodes.map((node) => (node.id === result.data.nodeId ? { ...node, title: result.data.title } : node)),
}))
return { ok: true, data: { title: result.data.title } }
}
if (!flow) return null
return (
<div data-slot="workflows" className="grid items-start gap-6 xl:grid-cols-[17rem_minmax(0,1fr)]">
<WorkflowList className="hidden xl:flex" flows={flows} openId={flow.id} now={now} onOpen={open} />
<div className="flex min-w-0 flex-col gap-6">
<WorkflowPicker className="xl:hidden" flows={flows} openId={flow.id} onOpen={open} />
<WorkflowCard
key={flow.id}
flow={flow}
stepId={stepId}
onStep={setStepId}
inspector={wide ? "inline" : "overlay"}
onToggle={toggle}
onRun={run}
onRename={rename}
notice={notice}
refusal={refusal}
now={now}
/>
<RunHistory flow={flow} now={now} playing={playing} />
</div>
</div>
)
}