@twoux/ui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +61 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE.md +64 -0
- package/README.md +211 -2
- package/SHADCN-DIFF.md +228 -0
- package/agent/AGENTS.md +358 -0
- package/agent/docs-md/accessibility.md +49 -0
- package/agent/docs-md/action-catalogue.md +81 -0
- package/agent/docs-md/actions.md +74 -0
- package/agent/docs-md/agent-activity-log.md +70 -0
- package/agent/docs-md/agent-permissions.md +82 -0
- package/agent/docs-md/agent-run.md +90 -0
- package/agent/docs-md/agents-on-behalf.md +74 -0
- package/agent/docs-md/ai-column.md +85 -0
- package/agent/docs-md/ai-origin-label.md +70 -0
- package/agent/docs-md/announcing-features.md +73 -0
- package/agent/docs-md/answer-actions.md +81 -0
- package/agent/docs-md/app-shell.md +114 -0
- package/agent/docs-md/artifact-card.md +89 -0
- package/agent/docs-md/ask-on-screen.md +72 -0
- package/agent/docs-md/assistant-page.md +100 -0
- package/agent/docs-md/assistant-panel.md +142 -0
- package/agent/docs-md/avatar.md +82 -0
- package/agent/docs-md/banner.md +106 -0
- package/agent/docs-md/board.md +163 -0
- package/agent/docs-md/breadcrumb.md +89 -0
- package/agent/docs-md/breakdown-table.md +97 -0
- package/agent/docs-md/bulk-action-bar.md +112 -0
- package/agent/docs-md/button.md +293 -0
- package/agent/docs-md/card.md +115 -0
- package/agent/docs-md/change-list.md +99 -0
- package/agent/docs-md/change-request.md +166 -0
- package/agent/docs-md/chart-area.md +87 -0
- package/agent/docs-md/chart-bar.md +100 -0
- package/agent/docs-md/chart-box-plot.md +88 -0
- package/agent/docs-md/chart-bullet.md +89 -0
- package/agent/docs-md/chart-candlestick.md +112 -0
- package/agent/docs-md/chart-card-line.md +120 -0
- package/agent/docs-md/chart-donut.md +90 -0
- package/agent/docs-md/chart-drill-down.md +72 -0
- package/agent/docs-md/chart-funnel.md +71 -0
- package/agent/docs-md/chart-heatmap.md +76 -0
- package/agent/docs-md/chart-histogram.md +81 -0
- package/agent/docs-md/chart-map.md +165 -0
- package/agent/docs-md/chart-mixed.md +88 -0
- package/agent/docs-md/chart-price.md +92 -0
- package/agent/docs-md/chart-radial-bars.md +109 -0
- package/agent/docs-md/chart-radial-lines.md +111 -0
- package/agent/docs-md/chart-sankey.md +104 -0
- package/agent/docs-md/chart-scatter.md +87 -0
- package/agent/docs-md/chart-share-bar.md +89 -0
- package/agent/docs-md/chart-treemap.md +98 -0
- package/agent/docs-md/chart-waterfall.md +93 -0
- package/agent/docs-md/checkbox.md +131 -0
- package/agent/docs-md/choosing-selection-control.md +71 -0
- package/agent/docs-md/citations-sources.md +79 -0
- package/agent/docs-md/citing-sources.md +63 -0
- package/agent/docs-md/clarify-before-acting.md +69 -0
- package/agent/docs-md/clarifying-question.md +92 -0
- package/agent/docs-md/code-block.md +174 -0
- package/agent/docs-md/colours.md +87 -0
- package/agent/docs-md/columns-menu.md +140 -0
- package/agent/docs-md/combobox.md +161 -0
- package/agent/docs-md/command-palette.md +126 -0
- package/agent/docs-md/confidence-note.md +87 -0
- package/agent/docs-md/configurable-dashboard.md +76 -0
- package/agent/docs-md/confirmation-dialog.md +93 -0
- package/agent/docs-md/connected-tools.md +80 -0
- package/agent/docs-md/connector-picker.md +127 -0
- package/agent/docs-md/consistency.md +53 -0
- package/agent/docs-md/content-density.md +61 -0
- package/agent/docs-md/conversation-history.md +78 -0
- package/agent/docs-md/create-record.md +80 -0
- package/agent/docs-md/csv-import.md +88 -0
- package/agent/docs-md/dashboard-items.md +78 -0
- package/agent/docs-md/data-export.md +61 -0
- package/agent/docs-md/data-grid.md +137 -0
- package/agent/docs-md/data-vis-colours.md +76 -0
- package/agent/docs-md/data-visualisation.md +76 -0
- package/agent/docs-md/date-picker.md +178 -0
- package/agent/docs-md/delete-record.md +75 -0
- package/agent/docs-md/dense-table.md +495 -0
- package/agent/docs-md/design-tokens.md +53 -0
- package/agent/docs-md/disabled-read-only.md +75 -0
- package/agent/docs-md/drag-and-drop.md +74 -0
- package/agent/docs-md/dropdown-menu.md +137 -0
- package/agent/docs-md/edit-record.md +73 -0
- package/agent/docs-md/empty-loading-error.md +78 -0
- package/agent/docs-md/empty-state.md +128 -0
- package/agent/docs-md/error-messages.md +75 -0
- package/agent/docs-md/error-state.md +123 -0
- package/agent/docs-md/export-menu.md +86 -0
- package/agent/docs-md/feedback-prompt.md +88 -0
- package/agent/docs-md/field-mapping.md +78 -0
- package/agent/docs-md/file-drop.md +110 -0
- package/agent/docs-md/filter-bar.md +138 -0
- package/agent/docs-md/filter-panel.md +103 -0
- package/agent/docs-md/filtering-search.md +79 -0
- package/agent/docs-md/follow-ups-starters.md +79 -0
- package/agent/docs-md/form-validation.md +95 -0
- package/agent/docs-md/generated-columns.md +65 -0
- package/agent/docs-md/generated-drafts-files.md +71 -0
- package/agent/docs-md/grouped-list.md +89 -0
- package/agent/docs-md/guide-cue.md +170 -0
- package/agent/docs-md/help-panel.md +103 -0
- package/agent/docs-md/help-system.md +74 -0
- package/agent/docs-md/iconography.md +49 -0
- package/agent/docs-md/inbox.md +108 -0
- package/agent/docs-md/inline-editing-validation.md +80 -0
- package/agent/docs-md/inline-input-in-answer.md +70 -0
- package/agent/docs-md/items-palette.md +116 -0
- package/agent/docs-md/keyboard-model.md +47 -0
- package/agent/docs-md/keyboard-navigation.md +68 -0
- package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
- package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
- package/agent/docs-md/labelling-ai-output.md +66 -0
- package/agent/docs-md/layout.md +47 -0
- package/agent/docs-md/list-table.md +254 -0
- package/agent/docs-md/loading-skeleton.md +91 -0
- package/agent/docs-md/managing-agents.md +71 -0
- package/agent/docs-md/message-composer.md +105 -0
- package/agent/docs-md/migrating.md +49 -0
- package/agent/docs-md/motion.md +93 -0
- package/agent/docs-md/navigation.md +76 -0
- package/agent/docs-md/notification-channels.md +65 -0
- package/agent/docs-md/notifications-decision.md +100 -0
- package/agent/docs-md/onboarding-step.md +114 -0
- package/agent/docs-md/order-book.md +108 -0
- package/agent/docs-md/page-header.md +96 -0
- package/agent/docs-md/pagination.md +88 -0
- package/agent/docs-md/pane-layout.md +105 -0
- package/agent/docs-md/period-comparison.md +66 -0
- package/agent/docs-md/plan-card.md +120 -0
- package/agent/docs-md/popover-rules.md +88 -0
- package/agent/docs-md/prompt-editor.md +90 -0
- package/agent/docs-md/propose-apply.md +65 -0
- package/agent/docs-md/provider-logo.md +97 -0
- package/agent/docs-md/radio.md +83 -0
- package/agent/docs-md/ranked-list.md +102 -0
- package/agent/docs-md/reasoning-progress.md +72 -0
- package/agent/docs-md/record-detail.md +71 -0
- package/agent/docs-md/regenerating-answers.md +70 -0
- package/agent/docs-md/run-status-bar.md +94 -0
- package/agent/docs-md/saved-tasks-schedules.md +63 -0
- package/agent/docs-md/saved-views.md +149 -0
- package/agent/docs-md/saving-views.md +68 -0
- package/agent/docs-md/scheduler.md +258 -0
- package/agent/docs-md/secondary-panels.md +72 -0
- package/agent/docs-md/segmented-control.md +88 -0
- package/agent/docs-md/selection-bulk-actions.md +70 -0
- package/agent/docs-md/separator.md +75 -0
- package/agent/docs-md/setup-checklist.md +92 -0
- package/agent/docs-md/shape-elevation.md +55 -0
- package/agent/docs-md/sheet-grid.md +211 -0
- package/agent/docs-md/side-panel.md +134 -0
- package/agent/docs-md/sign-in.md +114 -0
- package/agent/docs-md/spacing.md +55 -0
- package/agent/docs-md/split-button.md +100 -0
- package/agent/docs-md/stat-compare.md +82 -0
- package/agent/docs-md/static-dashboard.md +75 -0
- package/agent/docs-md/status-badge.md +140 -0
- package/agent/docs-md/step-card.md +75 -0
- package/agent/docs-md/stepper.md +92 -0
- package/agent/docs-md/suggested-changes.md +91 -0
- package/agent/docs-md/switch.md +78 -0
- package/agent/docs-md/table-settings.md +81 -0
- package/agent/docs-md/tabs.md +85 -0
- package/agent/docs-md/tag-input.md +108 -0
- package/agent/docs-md/tag.md +79 -0
- package/agent/docs-md/task-builder.md +87 -0
- package/agent/docs-md/team-instructions.md +72 -0
- package/agent/docs-md/templates-gallery.md +85 -0
- package/agent/docs-md/text-input.md +160 -0
- package/agent/docs-md/thinking-steps.md +89 -0
- package/agent/docs-md/timeline.md +189 -0
- package/agent/docs-md/timestamp.md +84 -0
- package/agent/docs-md/timestamps.md +70 -0
- package/agent/docs-md/toast.md +117 -0
- package/agent/docs-md/tooltip.md +90 -0
- package/agent/docs-md/typography.md +62 -0
- package/agent/docs-md/undo-destructive.md +66 -0
- package/agent/docs-md/unsaved-changes.md +71 -0
- package/agent/docs-md/usage-meter.md +80 -0
- package/agent/docs-md/user-feedback.md +71 -0
- package/agent/docs-md/voice-and-tone.md +60 -0
- package/agent/docs-md/why-choice.md +93 -0
- package/agent/docs-md/workspace-onboarding.md +74 -0
- package/agent/docs-md/writing.md +48 -0
- package/agent/llms-full.txt +18369 -0
- package/agent/llms.txt +198 -0
- package/agent/skills/twoux-audit/SKILL.md +49 -0
- package/agent/skills/twoux-build/SKILL.md +39 -0
- package/agent/skills/twoux-design/SKILL.md +54 -0
- package/agent/skills/twoux-write/SKILL.md +32 -0
- package/bin/check.mjs +123 -0
- package/bin/docs.mjs +24 -0
- package/bin/guard.mjs +27 -0
- package/bin/init.mjs +113 -0
- package/bin/knowledge.mjs +87 -0
- package/bin/mcp.mjs +116 -0
- package/bin/theme.mjs +168 -0
- package/bin/twoux.mjs +25 -0
- package/package.json +80 -3
- package/registry/jobs.json +1028 -0
- package/registry/palette.json +770 -0
- package/registry/rules.json +913 -0
- package/src/components/agent-review.tsx +364 -0
- package/src/components/ai-basics.tsx +212 -0
- package/src/components/ai-work.tsx +304 -0
- package/src/components/ai.tsx +227 -0
- package/src/components/alert-dialog.tsx +203 -0
- package/src/components/alert.tsx +86 -0
- package/src/components/avatar.tsx +114 -0
- package/src/components/badge.tsx +59 -0
- package/src/components/board.tsx +397 -0
- package/src/components/breadcrumb.tsx +114 -0
- package/src/components/button-group.tsx +90 -0
- package/src/components/button.tsx +109 -0
- package/src/components/calendar.tsx +247 -0
- package/src/components/card.tsx +102 -0
- package/src/components/chart-map.tsx +336 -0
- package/src/components/charts-more.tsx +1331 -0
- package/src/components/charts-radial.tsx +328 -0
- package/src/components/charts.tsx +390 -0
- package/src/components/checkbox.tsx +40 -0
- package/src/components/code.tsx +139 -0
- package/src/components/collapsible.tsx +37 -0
- package/src/components/combobox.tsx +339 -0
- package/src/components/command-extras.tsx +11 -0
- package/src/components/command.tsx +196 -0
- package/src/components/data-grid.tsx +213 -0
- package/src/components/data-table.tsx +334 -0
- package/src/components/data-views.tsx +740 -0
- package/src/components/date-picker.tsx +686 -0
- package/src/components/dense-table.tsx +207 -0
- package/src/components/dialog.tsx +162 -0
- package/src/components/dropdown-menu.tsx +264 -0
- package/src/components/empty.tsx +114 -0
- package/src/components/feedback-prompt.tsx +80 -0
- package/src/components/field.tsx +254 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/flow.tsx +840 -0
- package/src/components/guide-cue.tsx +273 -0
- package/src/components/inbox.tsx +113 -0
- package/src/components/input-group.tsx +176 -0
- package/src/components/input.tsx +26 -0
- package/src/components/kbd.tsx +34 -0
- package/src/components/label.tsx +27 -0
- package/src/components/labels.tsx +176 -0
- package/src/components/motion.tsx +136 -0
- package/src/components/onboarding.tsx +454 -0
- package/src/components/order-book.tsx +185 -0
- package/src/components/pane-layout.tsx +417 -0
- package/src/components/popover.tsx +96 -0
- package/src/components/provider-logos.data.ts +49 -0
- package/src/components/radio-group.tsx +52 -0
- package/src/components/scheduler.tsx +997 -0
- package/src/components/segmented-control.tsx +28 -0
- package/src/components/select.tsx +195 -0
- package/src/components/separator.tsx +31 -0
- package/src/components/sheet.tsx +148 -0
- package/src/components/shell.tsx +426 -0
- package/src/components/shortcut-sheet.tsx +83 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sonner.tsx +107 -0
- package/src/components/spinner.tsx +13 -0
- package/src/components/split-button.tsx +58 -0
- package/src/components/stepper.tsx +78 -0
- package/src/components/switch.tsx +43 -0
- package/src/components/table.tsx +126 -0
- package/src/components/tabs.tsx +118 -0
- package/src/components/tag-input.tsx +253 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/theme.tsx +80 -0
- package/src/components/timeline.tsx +408 -0
- package/src/components/toggle-group.tsx +87 -0
- package/src/components/toggle.tsx +54 -0
- package/src/components/tooltip.tsx +65 -0
- package/src/components/workspace-actions.tsx +294 -0
- package/src/components/workspace-import.tsx +465 -0
- package/src/components/workspace.tsx +1884 -0
- package/src/index.ts +80 -0
- package/src/lib/active-indicator.tsx +53 -0
- package/src/lib/chart-kit.tsx +72 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/shortcuts.tsx +61 -0
- package/src/lib/workspace.tsx +10 -0
- package/src/shadcn/alert-dialog.tsx +196 -0
- package/src/shadcn/alert.tsx +65 -0
- package/src/shadcn/avatar.tsx +108 -0
- package/src/shadcn/badge.tsx +47 -0
- package/src/shadcn/breadcrumb.tsx +108 -0
- package/src/shadcn/button-group.tsx +83 -0
- package/src/shadcn/button.tsx +63 -0
- package/src/shadcn/calendar.tsx +220 -0
- package/src/shadcn/card.tsx +91 -0
- package/src/shadcn/checkbox.tsx +31 -0
- package/src/shadcn/collapsible.tsx +33 -0
- package/src/shadcn/command.tsx +184 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/dropdown-menu.tsx +256 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +248 -0
- package/src/shadcn/input-group.tsx +170 -0
- package/src/shadcn/input.tsx +20 -0
- package/src/shadcn/kbd.tsx +28 -0
- package/src/shadcn/label.tsx +23 -0
- package/src/shadcn/popover.tsx +88 -0
- package/src/shadcn/radio-group.tsx +44 -0
- package/src/shadcn/select.tsx +189 -0
- package/src/shadcn/separator.tsx +27 -0
- package/src/shadcn/sheet.tsx +142 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/sonner.tsx +40 -0
- package/src/shadcn/switch.tsx +34 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +90 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +83 -0
- package/src/shadcn/toggle.tsx +46 -0
- package/src/shadcn/tooltip.tsx +56 -0
- package/src/styles/palette.css +127 -0
- package/src/styles/theme.css +35 -0
- package/src/styles/twoux.css +236 -0
- package/theme.json +14 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX Flow canvas: build an automation as connected steps, set each one up, and watch a test run move through it.
|
|
3
|
+
* Built on @xyflow/react (MIT) for pan, zoom, edge paths and the minimap. TwoUX changes to the reference tools (n8n, Vapi):
|
|
4
|
+
* - Type and state are written as words on every step; status colours only carry status, never the type of a step
|
|
5
|
+
* - Keyboard: arrows move focus to the nearest step in that direction; Space picks a step up, arrows move it,
|
|
6
|
+
* Enter drops, Esc puts it back (the TwoUX drag-and-drop pattern). Shift+F10 opens the step menu
|
|
7
|
+
* - Connections can be selected (click, or Tab and Enter): delete them, insert a step in them, or drag either end
|
|
8
|
+
* to another step. Every drag has a menu path in the step's ⋮ (WCAG 2.5.7)
|
|
9
|
+
* - FlowStepList shows the same flow as a nested list beside the canvas, with the same menu
|
|
10
|
+
* - Vertical or horizontal: `direction` turns the handles, and layoutFlow tidies the steps into layers
|
|
11
|
+
* - Moves, connections and deletions are announced in words in a polite live region (WCAG 4.1.3)
|
|
12
|
+
* - Selecting a step calls onSelect; the app opens its settings in SidePanel (record-detail pattern)
|
|
13
|
+
* - Deleting applies at once and the app offers Undo; the canvas never asks for confirmation
|
|
14
|
+
* - Locking stops moving and connecting, but steps can still be opened
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
Background,
|
|
18
|
+
BackgroundVariant,
|
|
19
|
+
EdgeLabelRenderer,
|
|
20
|
+
Handle,
|
|
21
|
+
MarkerType,
|
|
22
|
+
MiniMap,
|
|
23
|
+
Panel,
|
|
24
|
+
Position,
|
|
25
|
+
ReactFlow,
|
|
26
|
+
ReactFlowProvider,
|
|
27
|
+
getSmoothStepPath,
|
|
28
|
+
useNodesInitialized,
|
|
29
|
+
useReactFlow,
|
|
30
|
+
useUpdateNodeInternals,
|
|
31
|
+
type Connection,
|
|
32
|
+
type Edge,
|
|
33
|
+
type EdgeProps,
|
|
34
|
+
type Node,
|
|
35
|
+
type NodeChange,
|
|
36
|
+
type NodeProps,
|
|
37
|
+
} from "@xyflow/react"
|
|
38
|
+
import { AlertTriangle, CornerDownRight, Lock, LockOpen, Maximize, MoreVertical, Plus, Trash2, Users, ZoomIn, ZoomOut } from "lucide-react"
|
|
39
|
+
import * as React from "react"
|
|
40
|
+
import { cn } from "../lib/cn"
|
|
41
|
+
import { Badge } from "./badge"
|
|
42
|
+
import { Button } from "./button"
|
|
43
|
+
import {
|
|
44
|
+
DropdownMenu,
|
|
45
|
+
DropdownMenuContent,
|
|
46
|
+
DropdownMenuItem,
|
|
47
|
+
DropdownMenuSeparator,
|
|
48
|
+
DropdownMenuShortcut,
|
|
49
|
+
DropdownMenuSub,
|
|
50
|
+
DropdownMenuSubContent,
|
|
51
|
+
DropdownMenuSubTrigger,
|
|
52
|
+
DropdownMenuTrigger,
|
|
53
|
+
} from "./dropdown-menu"
|
|
54
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "./empty"
|
|
55
|
+
import { Kbd } from "./kbd"
|
|
56
|
+
import { ShimmerText } from "./motion"
|
|
57
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
|
|
58
|
+
|
|
59
|
+
/* ---------------------------------------------------------------- model */
|
|
60
|
+
|
|
61
|
+
export type FlowStepKind = "trigger" | "action" | "condition" | "review" | "end"
|
|
62
|
+
export type FlowRunState = "waiting" | "running" | "done" | "skipped" | "failed"
|
|
63
|
+
export type FlowBranch = "yes" | "no"
|
|
64
|
+
export type FlowDirection = "vertical" | "horizontal"
|
|
65
|
+
|
|
66
|
+
export type FlowStep = {
|
|
67
|
+
id: string
|
|
68
|
+
kind: FlowStepKind
|
|
69
|
+
/** What happens, verb first: "Screen CV", "Offer interview times" */
|
|
70
|
+
title: string
|
|
71
|
+
/** The step type the title came from, shown small above it: "Send email", "Application received" */
|
|
72
|
+
type?: string
|
|
73
|
+
/** One or two lines: what it reads, who it reaches, what it decides */
|
|
74
|
+
summary?: string
|
|
75
|
+
icon?: React.ReactNode
|
|
76
|
+
position: { x: number; y: number }
|
|
77
|
+
/** Why the step can't run yet: "choose an email template". Shown as "Needs set-up: …" */
|
|
78
|
+
issue?: string
|
|
79
|
+
/** Reaches people outside the workspace, such as emailing a candidate: "Emails the candidate" */
|
|
80
|
+
reaches?: string
|
|
81
|
+
/** State in the current or last run, with a note: "12 candidates", or the reason it failed */
|
|
82
|
+
run?: { state: FlowRunState; note?: string }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type FlowLink = { id: string; from: string; to: string; branch?: FlowBranch; /** e.g. "12 candidates" after a run */ label?: string }
|
|
86
|
+
export type FlowValue = { steps: FlowStep[]; links: FlowLink[] }
|
|
87
|
+
/** What changed, in words, for a toast with Undo: "Screen CV deleted" */
|
|
88
|
+
export type FlowChange = { type: "move" | "connect" | "disconnect" | "delete"; text: string }
|
|
89
|
+
|
|
90
|
+
export const FLOW_KIND_WORD: Record<FlowStepKind, string> = { trigger: "Trigger", action: "Action", condition: "Condition", review: "Review by a person", end: "End" }
|
|
91
|
+
const RUN_WORD: Record<FlowRunState, string> = { waiting: "Waiting", running: "Running", done: "Done", skipped: "Skipped", failed: "Failed" }
|
|
92
|
+
const BRANCH_WORD: Record<FlowBranch, string> = { yes: "Yes", no: "No" }
|
|
93
|
+
const STEP_W = 256
|
|
94
|
+
const GAP_Y = 168
|
|
95
|
+
|
|
96
|
+
/** The small word above the title: the step type, or its kind when the title already says the type. */
|
|
97
|
+
const typeWord = (s: FlowStep) => (s.type && s.type !== s.title && !s.title.startsWith(s.type) ? s.type : FLOW_KIND_WORD[s.kind])
|
|
98
|
+
const hasInput = (s: FlowStep) => s.kind !== "trigger"
|
|
99
|
+
const hasOutput = (s: FlowStep) => s.kind !== "end"
|
|
100
|
+
const linkId = (from: string, to: string, branch?: FlowBranch) => `${from}-${branch ?? "out"}-${to}`
|
|
101
|
+
|
|
102
|
+
/** Steps in flow order: triggers first, then breadth-first along the links (Yes before No); unreached steps last. */
|
|
103
|
+
export function flowOrder(value: FlowValue): FlowStep[] {
|
|
104
|
+
const byId = new Map(value.steps.map((s) => [s.id, s]))
|
|
105
|
+
const seen = new Set<string>()
|
|
106
|
+
const queue = value.steps.filter((s) => s.kind === "trigger").map((s) => s.id)
|
|
107
|
+
const out: FlowStep[] = []
|
|
108
|
+
while (queue.length) {
|
|
109
|
+
const id = queue.shift()!
|
|
110
|
+
if (seen.has(id) || !byId.has(id)) continue
|
|
111
|
+
seen.add(id)
|
|
112
|
+
out.push(byId.get(id)!)
|
|
113
|
+
const next = value.links.filter((l) => l.from === id).sort((a, b) => (a.branch === "no" ? 1 : 0) - (b.branch === "no" ? 1 : 0))
|
|
114
|
+
queue.push(...next.map((l) => l.to))
|
|
115
|
+
}
|
|
116
|
+
return [...out, ...value.steps.filter((s) => !seen.has(s.id))]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A free position for a new step after `fromId` (beside it for a condition's No branch). */
|
|
120
|
+
export function placeAfter(value: FlowValue, fromId?: string, branch?: FlowBranch, direction: FlowDirection = "vertical"): { x: number; y: number } {
|
|
121
|
+
const from = value.steps.find((s) => s.id === fromId)
|
|
122
|
+
const across = branch === "no" ? 1 : 0
|
|
123
|
+
const base = !from
|
|
124
|
+
? { x: 0, y: 0 }
|
|
125
|
+
: direction === "vertical"
|
|
126
|
+
? { x: from.position.x + across * (STEP_W + 48), y: from.position.y + GAP_Y }
|
|
127
|
+
: { x: from.position.x + STEP_W + 96, y: from.position.y + across * 200 }
|
|
128
|
+
const taken = (p: { x: number; y: number }) => value.steps.some((s) => Math.abs(s.position.x - p.x) < STEP_W && Math.abs(s.position.y - p.y) < GAP_Y - 24)
|
|
129
|
+
const p = { ...base }
|
|
130
|
+
while (taken(p)) direction === "vertical" ? (p.x += STEP_W + 48) : (p.y += 200)
|
|
131
|
+
return p
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Tidy the steps into layers along the flow: each step one layer after its furthest parent, placed across the layer
|
|
136
|
+
* near its parents (Yes before No), never overlapping. Vertical runs top to bottom, horizontal left to right.
|
|
137
|
+
*/
|
|
138
|
+
export function layoutFlow<V extends FlowValue>(value: V, direction: FlowDirection = "vertical"): V {
|
|
139
|
+
const MAIN = direction === "vertical" ? 200 : STEP_W + 112
|
|
140
|
+
const CROSS = direction === "vertical" ? STEP_W + 64 : 200
|
|
141
|
+
const order = flowOrder(value)
|
|
142
|
+
const rank = new Map(order.map((s, i) => [s.id, i]))
|
|
143
|
+
const depth = new Map<string, number>()
|
|
144
|
+
for (let pass = 0; pass < order.length; pass++) {
|
|
145
|
+
let changed = false
|
|
146
|
+
for (const s of order) {
|
|
147
|
+
const parents = value.links.filter((l) => l.to === s.id && rank.get(l.from)! < rank.get(s.id)!)
|
|
148
|
+
const d = parents.length ? Math.max(...parents.map((l) => (depth.get(l.from) ?? 0) + 1)) : 0
|
|
149
|
+
if (depth.get(s.id) !== d) { depth.set(s.id, d); changed = true }
|
|
150
|
+
}
|
|
151
|
+
if (!changed) break
|
|
152
|
+
}
|
|
153
|
+
const layers: FlowStep[][] = []
|
|
154
|
+
for (const s of order) (layers[depth.get(s.id) ?? 0] ??= []).push(s)
|
|
155
|
+
const cross = new Map<string, number>()
|
|
156
|
+
layers.forEach((layer, d) => {
|
|
157
|
+
if (!layer) return
|
|
158
|
+
const want = layer.map((s) => {
|
|
159
|
+
const ins = value.links.filter((l) => l.to === s.id && cross.has(l.from))
|
|
160
|
+
if (!ins.length) return 0
|
|
161
|
+
return ins.reduce((sum, l) => sum + cross.get(l.from)! + (l.branch === "yes" ? -CROSS / 2 : l.branch === "no" ? CROSS / 2 : 0), 0) / ins.length
|
|
162
|
+
})
|
|
163
|
+
const items = layer.map((s, i) => ({ s, want: d === 0 ? (i - (layer.length - 1) / 2) * CROSS : want[i] })).sort((a, b) => a.want - b.want)
|
|
164
|
+
const at = items.map((it) => it.want)
|
|
165
|
+
for (let i = 1; i < at.length; i++) at[i] = Math.max(at[i], at[i - 1] + CROSS)
|
|
166
|
+
const shift = items.reduce((sum, it, i) => sum + it.want - at[i], 0) / items.length
|
|
167
|
+
items.forEach((it, i) => cross.set(it.s.id, Math.round(at[i] + shift)))
|
|
168
|
+
})
|
|
169
|
+
return {
|
|
170
|
+
...value,
|
|
171
|
+
steps: value.steps.map((s) => {
|
|
172
|
+
const c = cross.get(s.id) ?? 0
|
|
173
|
+
const m = (depth.get(s.id) ?? 0) * MAIN
|
|
174
|
+
return { ...s, position: direction === "vertical" ? { x: c, y: m } : { x: m, y: c } }
|
|
175
|
+
}),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The step's full accessible name: title, type, set-up and run state, and how many connections leave it. */
|
|
180
|
+
function describe(step: FlowStep, value: FlowValue) {
|
|
181
|
+
const outs = value.links.filter((l) => l.from === step.id).length
|
|
182
|
+
return [
|
|
183
|
+
step.title,
|
|
184
|
+
FLOW_KIND_WORD[step.kind].toLowerCase(),
|
|
185
|
+
step.issue ? `needs set-up: ${step.issue}` : null,
|
|
186
|
+
step.run ? `${RUN_WORD[step.run.state].toLowerCase()}${step.run.note ? `, ${step.run.note}` : ""}` : null,
|
|
187
|
+
hasOutput(step) ? `${outs} ${outs === 1 ? "connection" : "connections"} out` : null,
|
|
188
|
+
].filter(Boolean).join(", ")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ---------------------------------------------------------------- actions shared by the canvas and the step list */
|
|
192
|
+
|
|
193
|
+
type Actions = {
|
|
194
|
+
value: FlowValue
|
|
195
|
+
locked: boolean
|
|
196
|
+
open: (id: string) => void
|
|
197
|
+
addAfter?: (id: string, branch?: FlowBranch) => void
|
|
198
|
+
insert?: (link: FlowLink) => void
|
|
199
|
+
connect: (from: string, to: string, branch?: FlowBranch) => void
|
|
200
|
+
disconnect: (link: FlowLink) => void
|
|
201
|
+
remove: (id: string) => void
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function useActions({ value, onValueChange, onSelect, onAddAfter, onInsert, locked, announce }: {
|
|
205
|
+
value: FlowValue
|
|
206
|
+
onValueChange: (next: FlowValue, change?: FlowChange) => void
|
|
207
|
+
onSelect: (id: string | null) => void
|
|
208
|
+
onAddAfter?: (id: string, branch?: FlowBranch) => void
|
|
209
|
+
onInsert?: (link: FlowLink) => void
|
|
210
|
+
locked: boolean
|
|
211
|
+
announce: (text: string) => void
|
|
212
|
+
}): Actions {
|
|
213
|
+
const title = (id: string) => value.steps.find((s) => s.id === id)?.title ?? "step"
|
|
214
|
+
const emit = (next: FlowValue, change: FlowChange) => { onValueChange(next, change); announce(change.text) }
|
|
215
|
+
return {
|
|
216
|
+
value,
|
|
217
|
+
locked,
|
|
218
|
+
open: onSelect,
|
|
219
|
+
addAfter: onAddAfter,
|
|
220
|
+
insert: onInsert,
|
|
221
|
+
connect: (from, to, branch) => {
|
|
222
|
+
if (from === to || value.links.some((l) => l.from === from && l.to === to && l.branch === branch)) return
|
|
223
|
+
const target = value.steps.find((s) => s.id === to)
|
|
224
|
+
if (!target || !hasInput(target)) return
|
|
225
|
+
const text = `Connected ${title(from)}${branch ? ` (${BRANCH_WORD[branch]})` : ""} to ${title(to)}`
|
|
226
|
+
emit({ ...value, links: [...value.links, { id: linkId(from, to, branch), from, to, branch }] }, { type: "connect", text })
|
|
227
|
+
},
|
|
228
|
+
disconnect: (link) => {
|
|
229
|
+
emit({ ...value, links: value.links.filter((l) => l.id !== link.id) }, { type: "disconnect", text: `Disconnected ${title(link.from)} from ${title(link.to)}` })
|
|
230
|
+
},
|
|
231
|
+
remove: (id) => {
|
|
232
|
+
onSelect(null)
|
|
233
|
+
emit({ steps: value.steps.filter((s) => s.id !== id), links: value.links.filter((l) => l.from !== id && l.to !== id) }, { type: "delete", text: `${title(id)} deleted` })
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The step's ⋮ menu: the non-drag path for everything the canvas does by dragging. No icons: words carry every item. */
|
|
239
|
+
function StepMenuItems({ step, actions }: { step: FlowStep; actions: Actions }) {
|
|
240
|
+
const { value, locked } = actions
|
|
241
|
+
const targets = value.steps.filter((s) => s.id !== step.id && hasInput(s))
|
|
242
|
+
const outs = value.links.filter((l) => l.from === step.id)
|
|
243
|
+
const branches: (FlowBranch | undefined)[] = step.kind === "condition" ? ["yes", "no"] : [undefined]
|
|
244
|
+
const name = (id: string) => value.steps.find((s) => s.id === id)?.title ?? id
|
|
245
|
+
return (
|
|
246
|
+
<>
|
|
247
|
+
<DropdownMenuItem onSelect={() => actions.open(step.id)}>Open settings<DropdownMenuShortcut>Enter</DropdownMenuShortcut></DropdownMenuItem>
|
|
248
|
+
{hasOutput(step) && actions.addAfter && !locked && branches.map((b) => (
|
|
249
|
+
<DropdownMenuItem key={b ?? "out"} onSelect={() => actions.addAfter!(step.id, b)}>Add step after{b ? ` ${BRANCH_WORD[b]}` : ""}</DropdownMenuItem>
|
|
250
|
+
))}
|
|
251
|
+
{hasOutput(step) && !locked && targets.length > 0 && branches.map((b) => (
|
|
252
|
+
<DropdownMenuSub key={`c-${b ?? "out"}`}>
|
|
253
|
+
<DropdownMenuSubTrigger>{b ? `Connect ${BRANCH_WORD[b]} to` : "Connect to"}</DropdownMenuSubTrigger>
|
|
254
|
+
<DropdownMenuSubContent className="max-h-72 overflow-y-auto">
|
|
255
|
+
{targets.map((t) => {
|
|
256
|
+
const linked = outs.some((l) => l.to === t.id && l.branch === b)
|
|
257
|
+
return <DropdownMenuItem key={t.id} disabled={linked} onSelect={() => actions.connect(step.id, t.id, b)}>{t.title}{linked && <span className="ml-auto text-sm text-ink-3">Connected</span>}</DropdownMenuItem>
|
|
258
|
+
})}
|
|
259
|
+
</DropdownMenuSubContent>
|
|
260
|
+
</DropdownMenuSub>
|
|
261
|
+
))}
|
|
262
|
+
{!locked && outs.length > 0 && (
|
|
263
|
+
<DropdownMenuSub>
|
|
264
|
+
<DropdownMenuSubTrigger>Disconnect</DropdownMenuSubTrigger>
|
|
265
|
+
<DropdownMenuSubContent>
|
|
266
|
+
{outs.map((l) => <DropdownMenuItem key={l.id} onSelect={() => actions.disconnect(l)}>{l.branch ? `${BRANCH_WORD[l.branch]}: ` : ""}{name(l.to)}</DropdownMenuItem>)}
|
|
267
|
+
</DropdownMenuSubContent>
|
|
268
|
+
</DropdownMenuSub>
|
|
269
|
+
)}
|
|
270
|
+
{!locked && (
|
|
271
|
+
<>
|
|
272
|
+
<DropdownMenuSeparator />
|
|
273
|
+
<DropdownMenuItem variant="destructive" onSelect={() => actions.remove(step.id)}>Delete step<DropdownMenuShortcut>Delete</DropdownMenuShortcut></DropdownMenuItem>
|
|
274
|
+
</>
|
|
275
|
+
)}
|
|
276
|
+
</>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ---------------------------------------------------------------- step parts */
|
|
281
|
+
|
|
282
|
+
/** Run state as a word: Running shimmers (off under reduced motion), the rest are badges. */
|
|
283
|
+
export function FlowRunBadge({ run }: { run: NonNullable<FlowStep["run"]> }) {
|
|
284
|
+
return (
|
|
285
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
286
|
+
{run.state === "running" ? (
|
|
287
|
+
<ShimmerText className="text-sm font-medium">Running</ShimmerText>
|
|
288
|
+
) : (
|
|
289
|
+
<Badge variant={run.state === "done" ? "success" : run.state === "failed" ? "destructive" : "secondary"}>{RUN_WORD[run.state]}</Badge>
|
|
290
|
+
)}
|
|
291
|
+
{run.note && <span className={cn("min-w-0 truncate text-sm", run.state === "failed" ? "text-bad" : "text-ink-2")}>{run.note}</span>}
|
|
292
|
+
</span>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function StepStatus({ step }: { step: FlowStep }) {
|
|
297
|
+
if (step.run) return <FlowRunBadge run={step.run} />
|
|
298
|
+
if (step.issue)
|
|
299
|
+
return (
|
|
300
|
+
<span className="flex min-w-0 items-start gap-1.5 text-sm text-warn">
|
|
301
|
+
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
|
302
|
+
<span className="min-w-0">Needs set-up: {step.issue}</span>
|
|
303
|
+
</span>
|
|
304
|
+
)
|
|
305
|
+
if (step.reaches)
|
|
306
|
+
return (
|
|
307
|
+
<span className="flex min-w-0 items-center gap-1.5 text-sm text-ink-3">
|
|
308
|
+
<Users className="size-3.5 shrink-0" aria-hidden="true" />
|
|
309
|
+
<span className="min-w-0 truncate">{step.reaches}</span>
|
|
310
|
+
</span>
|
|
311
|
+
)
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
type Ctx = Actions & { menuFor: string | null; setMenuFor: (id: string | null) => void; movingId: string | null; direction: FlowDirection; edgeSel: string | null; setEdgeSel: (id: string | null) => void }
|
|
316
|
+
const ActionsCtx = React.createContext<Ctx | null>(null)
|
|
317
|
+
|
|
318
|
+
const HANDLE = "size-2.5! min-w-0! rounded-full! border! border-control! bg-surface! before:absolute before:-inset-2 before:content-[''] data-[locked=true]:pointer-events-none"
|
|
319
|
+
|
|
320
|
+
function StepNode({ data, selected }: NodeProps<Node<{ step: FlowStep }>>) {
|
|
321
|
+
const { step } = data
|
|
322
|
+
const ctx = React.useContext(ActionsCtx)!
|
|
323
|
+
const failed = step.run?.state === "failed"
|
|
324
|
+
const vertical = ctx.direction === "vertical"
|
|
325
|
+
const moving = ctx.movingId === step.id
|
|
326
|
+
return (
|
|
327
|
+
<div
|
|
328
|
+
data-slot="flow-step"
|
|
329
|
+
data-moving={moving || undefined}
|
|
330
|
+
className={cn(
|
|
331
|
+
"relative grid w-64 gap-2 rounded-sm border bg-surface p-3 text-left shadow-card transition-[background-color,border-color,box-shadow] duration-(--duration-fast)",
|
|
332
|
+
selected ? "border-line-strong bg-selected" : "border-line",
|
|
333
|
+
step.run?.state === "running" && "border-primary",
|
|
334
|
+
failed && "border-bad-line",
|
|
335
|
+
step.run?.state === "skipped" && "opacity-70",
|
|
336
|
+
moving && "border-primary shadow-pop",
|
|
337
|
+
)}
|
|
338
|
+
>
|
|
339
|
+
{hasInput(step) && <Handle type="target" position={vertical ? Position.Top : Position.Left} className={HANDLE} data-locked={ctx.locked} aria-label={`Input of ${step.title}`} />}
|
|
340
|
+
<div className="flex items-start gap-2">
|
|
341
|
+
<span aria-hidden="true" className="grid size-7 shrink-0 place-items-center rounded-xs border border-line bg-surface-2 text-ink-2 [&>svg]:size-4">{step.icon}</span>
|
|
342
|
+
<span className="grid min-w-0 flex-1">
|
|
343
|
+
<span className="truncate text-sm text-ink-3">{typeWord(step)}</span>
|
|
344
|
+
<span className="truncate text-base font-medium text-ink">{step.title}</span>
|
|
345
|
+
</span>
|
|
346
|
+
<DropdownMenu open={ctx.menuFor === step.id} onOpenChange={(o) => ctx.setMenuFor(o ? step.id : null)}>
|
|
347
|
+
<DropdownMenuTrigger asChild>
|
|
348
|
+
<Button variant="ghost" size="icon-xs" tabIndex={-1} className="nodrag -mr-1 -mt-0.5" aria-label={`Actions for ${step.title}`}><MoreVertical /></Button>
|
|
349
|
+
</DropdownMenuTrigger>
|
|
350
|
+
<DropdownMenuContent align="start" className="w-56">
|
|
351
|
+
<StepMenuItems step={step} actions={ctx} />
|
|
352
|
+
</DropdownMenuContent>
|
|
353
|
+
</DropdownMenu>
|
|
354
|
+
</div>
|
|
355
|
+
{step.summary && <p className="line-clamp-2 text-sm text-ink-2">{step.summary}</p>}
|
|
356
|
+
<StepStatus step={step} />
|
|
357
|
+
{step.kind === "condition" ? (
|
|
358
|
+
<>
|
|
359
|
+
{vertical ? (
|
|
360
|
+
<span className="flex justify-between px-6 text-sm font-medium text-ink-2" aria-hidden="true"><span>Yes</span><span>No</span></span>
|
|
361
|
+
) : (
|
|
362
|
+
<>
|
|
363
|
+
<span aria-hidden="true" className="absolute top-1/3 left-full ml-2 -translate-y-full text-sm font-medium text-ink-2">Yes</span>
|
|
364
|
+
<span aria-hidden="true" className="absolute top-2/3 left-full ml-2 -translate-y-full text-sm font-medium text-ink-2">No</span>
|
|
365
|
+
</>
|
|
366
|
+
)}
|
|
367
|
+
<Handle type="source" id="yes" position={vertical ? Position.Bottom : Position.Right} className={cn(HANDLE, vertical ? "left-1/4!" : "top-1/3!")} data-locked={ctx.locked} aria-label={`Yes output of ${step.title}`} />
|
|
368
|
+
<Handle type="source" id="no" position={vertical ? Position.Bottom : Position.Right} className={cn(HANDLE, vertical ? "left-3/4!" : "top-2/3!")} data-locked={ctx.locked} aria-label={`No output of ${step.title}`} />
|
|
369
|
+
</>
|
|
370
|
+
) : (
|
|
371
|
+
hasOutput(step) && <Handle type="source" position={vertical ? Position.Bottom : Position.Right} className={HANDLE} data-locked={ctx.locked} aria-label={`Output of ${step.title}`} />
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function LinkEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd, data, selected }: EdgeProps<Edge<{ text?: string; active?: boolean; link: FlowLink; name: string }>>) {
|
|
378
|
+
const ctx = React.useContext(ActionsCtx)!
|
|
379
|
+
const [path, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 8 })
|
|
380
|
+
const showTools = selected && !ctx.locked && data
|
|
381
|
+
return (
|
|
382
|
+
<>
|
|
383
|
+
<path id={id} d={path} markerEnd={markerEnd} className={cn("react-flow__edge-path fill-none", selected ? "stroke-primary stroke-2" : data?.active ? "stroke-primary stroke-[1.5px]" : "stroke-control stroke-[1.5px]")} />
|
|
384
|
+
<path d={path} className="react-flow__edge-interaction cursor-pointer fill-none stroke-transparent stroke-[20px]" />
|
|
385
|
+
{(data?.text || showTools) && (
|
|
386
|
+
<EdgeLabelRenderer>
|
|
387
|
+
<span
|
|
388
|
+
className={cn("nodrag nopan absolute top-0 left-0 flex translate-x-[calc(var(--lx)-50%)] translate-y-[calc(var(--ly)-50%)] items-center", showTools ? "pointer-events-auto z-10 gap-0.5 rounded-md border border-line bg-popover p-0.5 shadow-pop" : "pointer-events-none")}
|
|
389
|
+
style={{ "--lx": `${labelX}px`, "--ly": `${labelY}px` } as React.CSSProperties}
|
|
390
|
+
>
|
|
391
|
+
{data?.text && <span className={cn("text-sm text-ink-2", showTools ? "px-1.5" : "rounded-xs border border-line bg-surface px-1.5")}>{data.text}</span>}
|
|
392
|
+
{showTools && (
|
|
393
|
+
<>
|
|
394
|
+
{ctx.insert && (
|
|
395
|
+
<Tooltip>
|
|
396
|
+
<TooltipTrigger asChild><Button variant="ghost" size="icon-xs" aria-label={`Insert a step between ${data.name}`} onClick={() => ctx.insert!(data.link)}><Plus /></Button></TooltipTrigger>
|
|
397
|
+
<TooltipContent>Insert step</TooltipContent>
|
|
398
|
+
</Tooltip>
|
|
399
|
+
)}
|
|
400
|
+
<Tooltip>
|
|
401
|
+
<TooltipTrigger asChild><Button variant="ghost" size="icon-xs" aria-label={`Delete the connection ${data.name}`} onClick={() => { ctx.setEdgeSel(null); ctx.disconnect(data.link) }}><Trash2 /></Button></TooltipTrigger>
|
|
402
|
+
<TooltipContent className="flex items-center gap-2">Delete connection<Kbd>Delete</Kbd></TooltipContent>
|
|
403
|
+
</Tooltip>
|
|
404
|
+
</>
|
|
405
|
+
)}
|
|
406
|
+
</span>
|
|
407
|
+
</EdgeLabelRenderer>
|
|
408
|
+
)}
|
|
409
|
+
</>
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const NODE_TYPES = { step: StepNode }
|
|
414
|
+
const EDGE_TYPES = { link: LinkEdge }
|
|
415
|
+
|
|
416
|
+
/* ---------------------------------------------------------------- canvas */
|
|
417
|
+
|
|
418
|
+
const reduced = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
419
|
+
const isMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform)
|
|
420
|
+
const ARROW: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
|
|
421
|
+
const DIR_WORD: Record<string, string> = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right" }
|
|
422
|
+
|
|
423
|
+
function ToolButton({ label, keys, onClick, pressed, children }: { label: string; keys?: string; onClick: () => void; pressed?: boolean; children: React.ReactNode }) {
|
|
424
|
+
return (
|
|
425
|
+
<Tooltip>
|
|
426
|
+
<TooltipTrigger asChild>
|
|
427
|
+
<Button variant="ghost" size="icon-sm" aria-label={label} aria-pressed={pressed} aria-keyshortcuts={keys} onClick={onClick}>{children}</Button>
|
|
428
|
+
</TooltipTrigger>
|
|
429
|
+
<TooltipContent className="flex items-center gap-2">{label}{keys && <Kbd>{keys.replace("Meta", "⌘").replace("Control", "Ctrl").replace(/\+/g, " ")}</Kbd>}</TooltipContent>
|
|
430
|
+
</Tooltip>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export type FlowCanvasProps = {
|
|
435
|
+
value: FlowValue
|
|
436
|
+
/** Every change, including each position while dragging. `change` is set once the move, connection or deletion is done: use it for a toast with Undo. */
|
|
437
|
+
onValueChange: (next: FlowValue, change?: FlowChange) => void
|
|
438
|
+
selectedId?: string | null
|
|
439
|
+
/** A step was selected (open its settings in SidePanel), or the canvas was clicked (null). */
|
|
440
|
+
onSelect: (id: string | null) => void
|
|
441
|
+
/** Add step after, from a step's menu or the step list. The app opens its step picker and places the step with placeAfter. */
|
|
442
|
+
onAddAfter?: (id: string, branch?: FlowBranch) => void
|
|
443
|
+
/** Insert step, from a selected connection. The app opens its step picker and puts the new step between link.from and link.to. */
|
|
444
|
+
onInsert?: (link: FlowLink) => void
|
|
445
|
+
/** Shown in the empty state as Add trigger. */
|
|
446
|
+
onAddTrigger?: () => void
|
|
447
|
+
/** Which way the flow runs: handles sit top and bottom (vertical) or left and right (horizontal). Use layoutFlow to rearrange. */
|
|
448
|
+
direction?: FlowDirection
|
|
449
|
+
locked?: boolean
|
|
450
|
+
onLockedChange?: (locked: boolean) => void
|
|
451
|
+
"aria-label": string
|
|
452
|
+
className?: string
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function CanvasInner({ value, onValueChange, selectedId = null, onSelect, onAddAfter, onInsert, onAddTrigger, direction = "vertical", locked = false, onLockedChange, className, "aria-label": label }: FlowCanvasProps) {
|
|
456
|
+
const flow = useReactFlow()
|
|
457
|
+
const [message, setMessage] = React.useState("")
|
|
458
|
+
const [menuFor, setMenuFor] = React.useState<string | null>(null)
|
|
459
|
+
const [edgeSel, setEdgeSel] = React.useState<string | null>(null)
|
|
460
|
+
const [moving, setMoving] = React.useState<{ id: string; from: { x: number; y: number } } | null>(null)
|
|
461
|
+
const announce = React.useCallback((text: string) => setMessage(text), [])
|
|
462
|
+
const actions = useActions({ value, onValueChange, onSelect, onAddAfter, onInsert, locked, announce })
|
|
463
|
+
const valueRef = React.useRef(value)
|
|
464
|
+
valueRef.current = value
|
|
465
|
+
const dragStart = React.useRef<FlowValue | null>(null)
|
|
466
|
+
const descId = React.useId()
|
|
467
|
+
// Controlled nodes must carry the sizes React Flow measures, or the minimap and framing have nothing to work with.
|
|
468
|
+
const [dims, setDims] = React.useState<Record<string, { width: number; height: number }>>({})
|
|
469
|
+
const [ready, setReady] = React.useState(false)
|
|
470
|
+
const box = React.useRef<HTMLDivElement>(null)
|
|
471
|
+
|
|
472
|
+
const nodes = React.useMemo<Node<{ step: FlowStep }>[]>(
|
|
473
|
+
() =>
|
|
474
|
+
flowOrder(value).map((s) => ({
|
|
475
|
+
id: s.id,
|
|
476
|
+
type: "step",
|
|
477
|
+
position: s.position,
|
|
478
|
+
data: { step: s },
|
|
479
|
+
selected: s.id === selectedId,
|
|
480
|
+
ariaLabel: describe(s, value),
|
|
481
|
+
deletable: false,
|
|
482
|
+
// Locked: a drag on a step does nothing (without nopan it would pan the canvas, so the step still seems to move).
|
|
483
|
+
className: locked ? "nopan cursor-default" : undefined,
|
|
484
|
+
measured: dims[s.id],
|
|
485
|
+
domAttributes: { "aria-describedby": descId },
|
|
486
|
+
})),
|
|
487
|
+
[value, selectedId, dims, descId, locked],
|
|
488
|
+
)
|
|
489
|
+
const edges = React.useMemo<Edge<{ text?: string; active?: boolean; link: FlowLink; name: string }>[]>(
|
|
490
|
+
() =>
|
|
491
|
+
value.links.map((l) => {
|
|
492
|
+
const to = value.steps.find((s) => s.id === l.to)
|
|
493
|
+
const from = value.steps.find((s) => s.id === l.from)
|
|
494
|
+
const text = [l.branch && BRANCH_WORD[l.branch], l.label].filter(Boolean).join(" · ") || undefined
|
|
495
|
+
const active = !!to?.run && to.run.state !== "waiting" && to.run.state !== "skipped"
|
|
496
|
+
const name = `${from?.title ?? l.from}${l.branch ? ` (${BRANCH_WORD[l.branch]})` : ""} and ${to?.title ?? l.to}`
|
|
497
|
+
return {
|
|
498
|
+
id: l.id,
|
|
499
|
+
type: "link",
|
|
500
|
+
source: l.from,
|
|
501
|
+
target: l.to,
|
|
502
|
+
sourceHandle: l.branch ?? null,
|
|
503
|
+
deletable: false,
|
|
504
|
+
selected: l.id === edgeSel,
|
|
505
|
+
ariaLabel: `Connection from ${from?.title ?? l.from}${l.branch ? `, ${BRANCH_WORD[l.branch]},` : ""} to ${to?.title ?? l.to}${l.label ? `, ${l.label}` : ""}`,
|
|
506
|
+
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: l.id === edgeSel || active ? "var(--color-primary)" : "var(--color-control)" },
|
|
507
|
+
data: { text, active, link: l, name },
|
|
508
|
+
}
|
|
509
|
+
}),
|
|
510
|
+
[value, edgeSel],
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
const onNodesChange = (changes: NodeChange<Node<{ step: FlowStep }>>[]) => {
|
|
514
|
+
let next = valueRef.current
|
|
515
|
+
for (const c of changes) {
|
|
516
|
+
if (c.type === "position" && c.position) {
|
|
517
|
+
const p = c.position
|
|
518
|
+
next = { ...next, steps: next.steps.map((s) => (s.id === c.id ? { ...s, position: { x: Math.round(p.x), y: Math.round(p.y) } } : s)) }
|
|
519
|
+
} else if (c.type === "dimensions" && c.dimensions) {
|
|
520
|
+
const d = c.dimensions
|
|
521
|
+
setDims((m) => (m[c.id]?.width === d.width && m[c.id]?.height === d.height ? m : { ...m, [c.id]: d }))
|
|
522
|
+
} else if (c.type === "select" && c.selected) {
|
|
523
|
+
setEdgeSel(null)
|
|
524
|
+
onSelect(c.id)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (next !== valueRef.current) onValueChange(next)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const fit = () => flow.fitView({ padding: 0.1, duration: reduced() ? 0 : 200 })
|
|
531
|
+
const zoom = (dir: 1 | -1) => (dir > 0 ? flow.zoomIn({ duration: reduced() ? 0 : 120 }) : flow.zoomOut({ duration: reduced() ? 0 : 120 }))
|
|
532
|
+
const mod = isMac() ? "Meta" : "Control"
|
|
533
|
+
|
|
534
|
+
// First view: the whole flow when it fits at a readable size (75% or more); otherwise its start, readable.
|
|
535
|
+
// Runs again when the direction changes, because the steps have moved.
|
|
536
|
+
const framedFor = React.useRef<string | null>(null)
|
|
537
|
+
React.useEffect(() => {
|
|
538
|
+
if (!ready || !box.current || framedFor.current === direction || value.steps.some((s) => !dims[s.id])) return
|
|
539
|
+
framedFor.current = direction
|
|
540
|
+
const b = flow.getNodesBounds(flow.getNodes())
|
|
541
|
+
const { clientWidth: w, clientHeight: h } = box.current
|
|
542
|
+
const whole = Math.min(w / (b.width + 96), h / (b.height + 96))
|
|
543
|
+
const z = Math.min(1, Math.max(0.75, whole))
|
|
544
|
+
const fits = whole >= 0.75
|
|
545
|
+
const x = direction === "vertical" || fits ? w / 2 - (b.x + b.width / 2) * z : 32 - b.x * z
|
|
546
|
+
const y = direction === "horizontal" || fits ? (h - b.height * z) / 2 - b.y * z : 32 - b.y * z
|
|
547
|
+
flow.setViewport({ x, y, zoom: z }, { duration: framedFor.current && !reduced() ? 200 : 0 })
|
|
548
|
+
}, [ready, dims, flow, value.steps, direction])
|
|
549
|
+
|
|
550
|
+
// Handles move to other sides when the direction changes; React Flow must measure them again or links keep the old sides.
|
|
551
|
+
const updateInternals = useUpdateNodeInternals()
|
|
552
|
+
React.useEffect(() => {
|
|
553
|
+
updateInternals(valueRef.current.steps.map((s) => s.id))
|
|
554
|
+
}, [direction, updateInternals])
|
|
555
|
+
|
|
556
|
+
// A step chosen elsewhere (the step list, a problem link) is brought into view.
|
|
557
|
+
React.useEffect(() => {
|
|
558
|
+
if (!selectedId || !box.current) return
|
|
559
|
+
const n = flow.getInternalNode(selectedId)
|
|
560
|
+
if (!n?.measured.width) return
|
|
561
|
+
const { x, y, zoom: z } = flow.getViewport()
|
|
562
|
+
const p = n.internals.positionAbsolute
|
|
563
|
+
const left = p.x * z + x, top = p.y * z + y, right = left + n.measured.width * z, bottom = top + (n.measured.height ?? 0) * z
|
|
564
|
+
const { clientWidth: w, clientHeight: h } = box.current
|
|
565
|
+
if (left < 0 || top < 0 || right > w || bottom > h) flow.setCenter(p.x + n.measured.width / 2, p.y + (n.measured.height ?? 0) / 2, { zoom: z, duration: reduced() ? 0 : 200 })
|
|
566
|
+
}, [selectedId, flow])
|
|
567
|
+
|
|
568
|
+
const focusStep = (id: string) => box.current?.querySelector<HTMLElement>(`.react-flow__node[data-id="${CSS.escape(id)}"]`)?.focus()
|
|
569
|
+
|
|
570
|
+
/** The nearest step in the arrow's direction, judged by what people see: distance along it, with sideways drift costing double. */
|
|
571
|
+
const nearest = (fromId: string, key: string) => {
|
|
572
|
+
const [dx, dy] = ARROW[key]
|
|
573
|
+
// Along the arrow, distance between the steps' leading edges (so a shorter card isn't "nearer"); sideways, between centres.
|
|
574
|
+
const centre = (s: FlowStep) => ({ x: s.position.x + (dx ? 0 : (dims[s.id]?.width ?? STEP_W) / 2), y: s.position.y + (dy ? 0 : (dims[s.id]?.height ?? 100) / 2) })
|
|
575
|
+
const from = value.steps.find((s) => s.id === fromId)
|
|
576
|
+
if (!from) return undefined
|
|
577
|
+
const c = centre(from)
|
|
578
|
+
let best: { id: string; score: number } | undefined
|
|
579
|
+
for (const s of flowOrder(value)) {
|
|
580
|
+
if (s.id === fromId) continue
|
|
581
|
+
const p = centre(s)
|
|
582
|
+
const along = (p.x - c.x) * dx + (p.y - c.y) * dy
|
|
583
|
+
if (along <= 8) continue
|
|
584
|
+
const side = Math.abs((p.x - c.x) * dy) + Math.abs((p.y - c.y) * dx)
|
|
585
|
+
const score = along + 2 * side
|
|
586
|
+
if (!best || score < best.score) best = { id: s.id, score }
|
|
587
|
+
}
|
|
588
|
+
return best?.id
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const finishMove = (cancel: boolean) => {
|
|
592
|
+
if (!moving) return
|
|
593
|
+
const s = valueRef.current.steps.find((x) => x.id === moving.id)
|
|
594
|
+
if (cancel) {
|
|
595
|
+
onValueChange({ ...valueRef.current, steps: valueRef.current.steps.map((x) => (x.id === moving.id ? { ...x, position: moving.from } : x)) })
|
|
596
|
+
announce(`Move cancelled. ${s?.title ?? "Step"} is back where it was.`)
|
|
597
|
+
} else if (s) {
|
|
598
|
+
onValueChange(valueRef.current, { type: "move", text: `${s.title} moved` })
|
|
599
|
+
announce(`${s.title} dropped`)
|
|
600
|
+
}
|
|
601
|
+
setMoving(null)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Capture phase, so these keys win over React Flow's own (which would move a selected step with the arrows).
|
|
605
|
+
const onKeyDownCapture = (e: React.KeyboardEvent) => {
|
|
606
|
+
const t = e.target as HTMLElement
|
|
607
|
+
if (t.closest("input, textarea, select, [contenteditable=true], [role=menu], [role=menuitem]")) return
|
|
608
|
+
const nodeEl = t.closest<HTMLElement>(".react-flow__node")
|
|
609
|
+
const edgeEl = t.closest<SVGElement>(".react-flow__edge")
|
|
610
|
+
const id = nodeEl?.dataset.id
|
|
611
|
+
const stop = () => { e.preventDefault(); e.stopPropagation() }
|
|
612
|
+
|
|
613
|
+
if (moving && id === moving.id) {
|
|
614
|
+
if (e.key in ARROW) {
|
|
615
|
+
stop()
|
|
616
|
+
const [dx, dy] = ARROW[e.key]
|
|
617
|
+
const step = e.shiftKey ? 40 : 8
|
|
618
|
+
onValueChange({ ...valueRef.current, steps: valueRef.current.steps.map((s) => (s.id === id ? { ...s, position: { x: s.position.x + dx * step, y: s.position.y + dy * step } } : s)) })
|
|
619
|
+
announce(`Moved ${DIR_WORD[e.key]}`)
|
|
620
|
+
} else if (e.key === "Enter" || e.key === " ") { stop(); finishMove(false) }
|
|
621
|
+
else if (e.key === "Escape") { stop(); finishMove(true) }
|
|
622
|
+
else if (e.key === "Tab") finishMove(false)
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "=" || e.key === "+")) { stop(); zoom(1) }
|
|
626
|
+
else if ((e.metaKey || e.ctrlKey) && e.key === "-") { stop(); zoom(-1) }
|
|
627
|
+
else if (e.shiftKey && (e.key === "!" || e.code === "Digit1")) { stop(); fit() }
|
|
628
|
+
else if (id && e.shiftKey && e.key === "F10") { stop(); setMenuFor(id) }
|
|
629
|
+
else if (id && e.key === "Enter") { stop(); setEdgeSel(null); onSelect(id) }
|
|
630
|
+
else if (id && e.key === " " && !locked) {
|
|
631
|
+
stop()
|
|
632
|
+
const s = value.steps.find((x) => x.id === id)
|
|
633
|
+
if (!s) return
|
|
634
|
+
setMoving({ id, from: s.position })
|
|
635
|
+
announce(`Picked up ${s.title}. Use the arrow keys to move it, Enter to drop, Escape to cancel.`)
|
|
636
|
+
} else if (id && e.key in ARROW && !e.altKey && !e.metaKey && !e.ctrlKey) {
|
|
637
|
+
stop()
|
|
638
|
+
const to = nearest(id, e.key)
|
|
639
|
+
if (to) focusStep(to)
|
|
640
|
+
} else if (id && (e.key === "Home" || e.key === "End")) {
|
|
641
|
+
stop()
|
|
642
|
+
const order = flowOrder(value)
|
|
643
|
+
const ends = order.filter((s) => s.kind === "end")
|
|
644
|
+
const to = e.key === "Home" ? order[0] : (ends.at(-1) ?? order[order.length - 1])
|
|
645
|
+
if (to) focusStep(to.id)
|
|
646
|
+
} else if (edgeEl && e.key === "Enter") { stop(); setEdgeSel(edgeEl.dataset.id ?? null) }
|
|
647
|
+
else if (!locked && (e.key === "Delete" || e.key === "Backspace")) {
|
|
648
|
+
const link = value.links.find((l) => l.id === (edgeEl?.dataset.id ?? edgeSel))
|
|
649
|
+
if (link) { stop(); setEdgeSel(null); actions.disconnect(link) }
|
|
650
|
+
else if (id || selectedId) { stop(); actions.remove(id ?? selectedId!) }
|
|
651
|
+
} else if (e.key === "Escape" && edgeSel) { stop(); setEdgeSel(null) }
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return (
|
|
655
|
+
<ActionsCtx.Provider value={{ ...actions, menuFor, setMenuFor, movingId: moving?.id ?? null, direction, edgeSel, setEdgeSel }}>
|
|
656
|
+
<div ref={box} data-slot="flow-canvas" className={cn("relative h-full min-h-0 w-full bg-bg", className)} onKeyDownCapture={onKeyDownCapture}>
|
|
657
|
+
<ReactFlow
|
|
658
|
+
aria-label={label}
|
|
659
|
+
nodes={nodes}
|
|
660
|
+
edges={edges}
|
|
661
|
+
nodeTypes={NODE_TYPES}
|
|
662
|
+
edgeTypes={EDGE_TYPES}
|
|
663
|
+
onNodesChange={onNodesChange}
|
|
664
|
+
onInit={() => setReady(true)}
|
|
665
|
+
onNodeDragStart={() => { dragStart.current = valueRef.current }}
|
|
666
|
+
onNodeDragStop={(_, n) => {
|
|
667
|
+
const s = valueRef.current.steps.find((x) => x.id === n.id)
|
|
668
|
+
if (s && dragStart.current) onValueChange(valueRef.current, { type: "move", text: `${s.title} moved` })
|
|
669
|
+
dragStart.current = null
|
|
670
|
+
}}
|
|
671
|
+
onEdgeClick={(_, edge) => { setEdgeSel(edge.id); if (selectedId) onSelect(null) }}
|
|
672
|
+
onConnect={(c: Connection) => c.source && c.target && actions.connect(c.source, c.target, (c.sourceHandle as FlowBranch | null) ?? undefined)}
|
|
673
|
+
onReconnect={(old, c) => {
|
|
674
|
+
const v = valueRef.current
|
|
675
|
+
const link = v.links.find((l) => l.id === old.id)
|
|
676
|
+
if (!link || !c.source || !c.target || c.source === c.target) return
|
|
677
|
+
const branch = (c.sourceHandle as FlowBranch | null) ?? undefined
|
|
678
|
+
const title = (sid: string) => v.steps.find((s) => s.id === sid)?.title ?? "step"
|
|
679
|
+
const text = `Connection now runs from ${title(c.source)}${branch ? ` (${BRANCH_WORD[branch]})` : ""} to ${title(c.target)}`
|
|
680
|
+
onValueChange({ ...v, links: v.links.map((l) => (l.id === old.id ? { id: linkId(c.source, c.target, branch), from: c.source, to: c.target, branch } : l)) }, { type: "connect", text })
|
|
681
|
+
announce(text)
|
|
682
|
+
setEdgeSel(null)
|
|
683
|
+
}}
|
|
684
|
+
isValidConnection={(c) => c.source !== c.target && !!value.steps.find((s) => s.id === c.target && hasInput(s))}
|
|
685
|
+
onPaneClick={() => { setEdgeSel(null); onSelect(null) }}
|
|
686
|
+
nodesDraggable={!locked}
|
|
687
|
+
nodesConnectable={!locked}
|
|
688
|
+
edgesReconnectable={!locked}
|
|
689
|
+
reconnectRadius={16}
|
|
690
|
+
disableKeyboardA11y
|
|
691
|
+
deleteKeyCode={null}
|
|
692
|
+
selectionKeyCode={null}
|
|
693
|
+
multiSelectionKeyCode={null}
|
|
694
|
+
panActivationKeyCode={null}
|
|
695
|
+
snapToGrid
|
|
696
|
+
snapGrid={[8, 8]}
|
|
697
|
+
minZoom={0.25}
|
|
698
|
+
maxZoom={1.5}
|
|
699
|
+
proOptions={{ hideAttribution: false }}
|
|
700
|
+
attributionPosition="bottom-left"
|
|
701
|
+
ariaLabelConfig={{ "edge.a11yDescription.default": "Press Enter to select, then Delete to remove it.", "minimap.ariaLabel": "Overview of the flow", "handle.ariaLabel": "Connection point" }}
|
|
702
|
+
>
|
|
703
|
+
<Background variant={BackgroundVariant.Dots} gap={16} size={1} color="var(--color-line-strong)" />
|
|
704
|
+
<MiniMap pannable zoomable ariaLabel="Overview of the flow" className="rounded-sm border border-line max-md:hidden" nodeBorderRadius={4} />
|
|
705
|
+
<Panel position="bottom-center">
|
|
706
|
+
<div role="toolbar" aria-label="Canvas" className="flex items-center gap-0.5 rounded-md border border-line bg-popover p-1 shadow-pop">
|
|
707
|
+
<ToolButton label="Fit view" keys="Shift+1" onClick={fit}><Maximize /></ToolButton>
|
|
708
|
+
<ToolButton label="Zoom out" keys={`${mod}+-`} onClick={() => zoom(-1)}><ZoomOut /></ToolButton>
|
|
709
|
+
<ToolButton label="Zoom in" keys={`${mod}+=`} onClick={() => zoom(1)}><ZoomIn /></ToolButton>
|
|
710
|
+
{onLockedChange && (
|
|
711
|
+
<>
|
|
712
|
+
<span className="mx-0.5 h-4 w-px bg-line" aria-hidden="true" />
|
|
713
|
+
<ToolButton label={locked ? "Unlock canvas" : "Lock canvas"} pressed={locked} onClick={() => { onLockedChange(!locked); announce(locked ? "Canvas unlocked" : "Canvas locked. Steps can be opened but not moved, connected or deleted.") }}>
|
|
714
|
+
{locked ? <Lock /> : <LockOpen />}
|
|
715
|
+
</ToolButton>
|
|
716
|
+
{locked && <span aria-hidden="true" className="pr-2 pl-0.5 text-sm text-ink-2">Locked</span>}
|
|
717
|
+
</>
|
|
718
|
+
)}
|
|
719
|
+
</div>
|
|
720
|
+
</Panel>
|
|
721
|
+
</ReactFlow>
|
|
722
|
+
{value.steps.length === 0 && (
|
|
723
|
+
<div className="pointer-events-none absolute inset-0 grid place-items-center p-4">
|
|
724
|
+
<Empty className="pointer-events-auto max-w-sm rounded-sm border border-line bg-surface shadow-card">
|
|
725
|
+
<EmptyHeader>
|
|
726
|
+
<EmptyTitle level={2}>No steps yet</EmptyTitle>
|
|
727
|
+
<EmptyDescription>Add a trigger to say what starts this workflow, such as a new application.</EmptyDescription>
|
|
728
|
+
</EmptyHeader>
|
|
729
|
+
{onAddTrigger && <EmptyContent><Button size="sm" onClick={onAddTrigger}><Plus />Add trigger</Button></EmptyContent>}
|
|
730
|
+
</Empty>
|
|
731
|
+
</div>
|
|
732
|
+
)}
|
|
733
|
+
<p id={descId} className="sr-only">
|
|
734
|
+
{locked
|
|
735
|
+
? "Arrow keys go to the next step in that direction. Enter opens settings, Shift F10 opens actions."
|
|
736
|
+
: "Arrow keys go to the next step in that direction. Enter opens settings, Space picks the step up to move it, Shift F10 opens actions, Delete deletes."}
|
|
737
|
+
</p>
|
|
738
|
+
<p role="status" aria-live="polite" className="sr-only">{message}</p>
|
|
739
|
+
</div>
|
|
740
|
+
</ActionsCtx.Provider>
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** The flow as connected steps on a canvas people pan and zoom. Fills its parent: give it a height. */
|
|
745
|
+
export function FlowCanvas(props: FlowCanvasProps) {
|
|
746
|
+
return (
|
|
747
|
+
<ReactFlowProvider>
|
|
748
|
+
<CanvasInner {...props} />
|
|
749
|
+
</ReactFlowProvider>
|
|
750
|
+
)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/* ---------------------------------------------------------------- step list */
|
|
754
|
+
|
|
755
|
+
export type FlowStepListProps = Omit<FlowCanvasProps, "onLockedChange" | "aria-label" | "direction" | "onInsert"> & { "aria-label": string }
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The same flow as a nested list, shown beside the canvas: steps in flow order, a condition's steps under If yes and
|
|
759
|
+
* If no, a step reached twice written as "Continues to …". Choosing a row selects that step on the canvas too. Every row
|
|
760
|
+
* has the step menu, so adding, connecting and deleting never need the canvas.
|
|
761
|
+
*/
|
|
762
|
+
export function FlowStepList({ value, onValueChange, selectedId = null, onSelect, onAddAfter, onAddTrigger, locked = false, className, "aria-label": label }: FlowStepListProps) {
|
|
763
|
+
const [message, setMessage] = React.useState("")
|
|
764
|
+
const actions = useActions({ value, onValueChange, onSelect, onAddAfter, locked, announce: setMessage })
|
|
765
|
+
const byId = new Map(value.steps.map((s) => [s.id, s]))
|
|
766
|
+
const placed = new Set<string>()
|
|
767
|
+
|
|
768
|
+
const row = (s: FlowStep) => (
|
|
769
|
+
<div className={cn("flex items-start gap-1 rounded-xs py-1 pr-1 pl-2", s.id === selectedId ? "bg-selected" : "hover:bg-hover")}>
|
|
770
|
+
<button type="button" onClick={() => onSelect(s.id)} aria-current={s.id === selectedId || undefined} className="flex min-w-0 flex-1 items-start gap-2 rounded-xs py-0.5 text-left outline-none focus-visible:ring-3 focus-visible:ring-primary/20">
|
|
771
|
+
<span aria-hidden="true" className="mt-0.5 grid size-5 shrink-0 place-items-center text-ink-3 [&>svg]:size-3.5">{s.icon}</span>
|
|
772
|
+
<span className="grid min-w-0 flex-1">
|
|
773
|
+
<span className="truncate text-base text-ink">{s.title}</span>
|
|
774
|
+
{(s.run || s.issue) && <StepStatus step={s} />}
|
|
775
|
+
</span>
|
|
776
|
+
</button>
|
|
777
|
+
<DropdownMenu>
|
|
778
|
+
<DropdownMenuTrigger asChild>
|
|
779
|
+
<Button variant="ghost" size="icon-xs" aria-label={`Actions for ${s.title}`}><MoreVertical /></Button>
|
|
780
|
+
</DropdownMenuTrigger>
|
|
781
|
+
<DropdownMenuContent align="end" className="w-56"><StepMenuItems step={s} actions={actions} /></DropdownMenuContent>
|
|
782
|
+
</DropdownMenu>
|
|
783
|
+
</div>
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
const branch = (s: FlowStep, b: FlowBranch | undefined): React.ReactNode =>
|
|
787
|
+
value.links.filter((l) => l.from === s.id && l.branch === b).map((l) => {
|
|
788
|
+
const t = byId.get(l.to)
|
|
789
|
+
if (!t) return null
|
|
790
|
+
if (placed.has(t.id))
|
|
791
|
+
return (
|
|
792
|
+
<li key={l.id} className="flex items-center gap-1.5 py-1 pl-2 text-sm text-ink-2">
|
|
793
|
+
<CornerDownRight className="size-3.5 text-ink-3" aria-hidden="true" />Continues to <button type="button" className="rounded-xs font-medium text-primary-ink hover:underline" onClick={() => onSelect(t.id)}>{t.title}</button>
|
|
794
|
+
</li>
|
|
795
|
+
)
|
|
796
|
+
return <React.Fragment key={l.id}>{item(t)}</React.Fragment>
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
const item = (s: FlowStep): React.ReactNode => {
|
|
800
|
+
placed.add(s.id)
|
|
801
|
+
return (
|
|
802
|
+
<li key={s.id} className="grid">
|
|
803
|
+
{row(s)}
|
|
804
|
+
{s.kind === "condition" ? (
|
|
805
|
+
(["yes", "no"] as const).map((b) => (
|
|
806
|
+
<div key={b} className="ml-4 grid border-l border-line pl-2">
|
|
807
|
+
<p className="pt-1 pl-2 text-sm font-medium text-ink-2">If {BRANCH_WORD[b].toLowerCase()}</p>
|
|
808
|
+
<ul className="grid">{value.links.some((l) => l.from === s.id && l.branch === b) ? branch(s, b) : <li className="py-1 pl-2 text-sm text-ink-3">No step yet</li>}</ul>
|
|
809
|
+
</div>
|
|
810
|
+
))
|
|
811
|
+
) : (
|
|
812
|
+
<ul className="grid">{branch(s, undefined)}</ul>
|
|
813
|
+
)}
|
|
814
|
+
</li>
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const tree = value.steps.filter((s) => s.kind === "trigger").map((r) => item(r))
|
|
819
|
+
const loose = value.steps.filter((s) => !placed.has(s.id))
|
|
820
|
+
|
|
821
|
+
return (
|
|
822
|
+
<div data-slot="flow-step-list" className={cn("grid content-start gap-4", className)}>
|
|
823
|
+
{value.steps.length === 0 ? (
|
|
824
|
+
<Empty className="rounded-sm border border-line">
|
|
825
|
+
<EmptyHeader><EmptyTitle level={2}>No steps yet</EmptyTitle><EmptyDescription>Add a trigger to say what starts this workflow, such as a new application.</EmptyDescription></EmptyHeader>
|
|
826
|
+
{onAddTrigger && <EmptyContent><Button size="sm" onClick={onAddTrigger}><Plus />Add trigger</Button></EmptyContent>}
|
|
827
|
+
</Empty>
|
|
828
|
+
) : (
|
|
829
|
+
<ul aria-label={label} className="grid">{tree}</ul>
|
|
830
|
+
)}
|
|
831
|
+
{loose.length > 0 && (
|
|
832
|
+
<section aria-label="Not connected" className="grid gap-1">
|
|
833
|
+
<h3 className="pl-2 text-sm font-medium text-ink-2">Not connected · {loose.length}</h3>
|
|
834
|
+
<ul className="grid">{loose.map((s) => <li key={s.id}>{row(s)}</li>)}</ul>
|
|
835
|
+
</section>
|
|
836
|
+
)}
|
|
837
|
+
<p role="status" aria-live="polite" className="sr-only">{message}</p>
|
|
838
|
+
</div>
|
|
839
|
+
)
|
|
840
|
+
}
|