From 43abe04958ef4d090659e0f66fddd78df20733cd Mon Sep 17 00:00:00 2001 From: Vladimir Mujakovic Date: Wed, 20 May 2026 16:19:49 -0400 Subject: [PATCH 01/11] intro component --- DESIGN.md | 157 +-- README.md | 117 ++- .../ProcessFlow/ProcessFlow.stories.tsx | 648 +++++++++++++ .../composed/ProcessFlow/ProcessFlow.test.tsx | 198 ++++ .../composed/ProcessFlow/ProcessFlow.tsx | 897 ++++++++++++++++++ src/components/composed/ProcessFlow/index.ts | 1 + src/index.tailwind.css | 90 ++ src/index.ts | 3 +- 8 files changed, 1995 insertions(+), 116 deletions(-) create mode 100644 src/components/composed/ProcessFlow/ProcessFlow.stories.tsx create mode 100644 src/components/composed/ProcessFlow/ProcessFlow.test.tsx create mode 100644 src/components/composed/ProcessFlow/ProcessFlow.tsx create mode 100644 src/components/composed/ProcessFlow/index.ts diff --git a/DESIGN.md b/DESIGN.md index 44674936..72dced8d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -62,85 +62,86 @@ import { CHART_COLORS, COLORS } from "@tetrascience-npm/tetrascience-react-ui"; ## 3. Component Inventory -| Component | Category | Base (shadcn) | Key Additions | Status | -| ------------------------ | ------------ | --------------------- | ------------------------------------------ | ------ | -| `Button` | Action | `button` | 8 size variants, icon-only mode | Stable | -| `ButtonGroup` | Action | — | Segmented button container | Stable | -| `Toggle` | Action | `toggle` | Pressable on/off button | Stable | -| `ToggleGroup` | Action | `toggle-group` | Exclusive/multi-select toggle set | Stable | -| `Input` | Form | `input` | — | Stable | -| `InputGroup` | Form | — | Leading/trailing adornments | Stable | -| `Field` | Form | — | Label + input + error wrapper | Stable | -| `Label` | Form | `label` | — | Stable | -| `Select` | Form | `select` | — | Stable | -| `Combobox` | Form | `command` | Searchable select | Stable | -| `Checkbox` | Form | `checkbox` | — | Stable | -| `RadioGroup` | Form | `radio-group` | — | Stable | -| `Switch` | Form | `switch` | — | Stable | -| `Textarea` | Form | `textarea` | — | Stable | -| `Calendar` | Form | `calendar` | — | Stable | -| `InputOtp` | Form | `input-otp` | — | Stable | -| `Slider` | Form | `slider` | — | Stable | -| `CodeEditor` | Form | — | Monaco editor, theme-aware | Stable | -| `Dialog` | Overlay | `dialog` | — | Stable | -| `AlertDialog` | Overlay | `alert-dialog` | — | Stable | -| `Sheet` | Overlay | `sheet` | — | Stable | -| `Drawer` | Overlay | `drawer` | — | Stable | -| `Tooltip` | Overlay | `tooltip` | — | Stable | -| `HoverCard` | Overlay | `hover-card` | — | Stable | -| `Command` | Overlay | `command` | Command palette / search | Stable | -| `DropdownMenu` | Navigation | `dropdown-menu` | — | Stable | -| `ContextMenu` | Navigation | `context-menu` | — | Stable | -| `Menubar` | Navigation | `menubar` | — | Stable | -| `NavigationMenu` | Navigation | `navigation-menu` | — | Stable | -| `Breadcrumb` | Navigation | `breadcrumb` | — | Stable | -| `Tabs` | Navigation | `tabs` | — | Stable | -| `Sidebar` | Navigation | `sidebar` | App-level sidebar pattern | Stable | -| `Alert` | Feedback | `alert` | — | Stable | -| `Badge` | Feedback | `badge` | — | Stable | -| `Skeleton` | Feedback | `skeleton` | — | Stable | -| `Spinner` | Feedback | — | Loading indicator | Stable | -| `Sonner` | Feedback | `sonner` | Toast notifications | Stable | -| `Table` | Data Display | `table` | — | Stable | +| Component | Category | Base (shadcn) | Key Additions | Status | +| ------------------------ | ------------ | --------------------- | ------------------------------------------------- | ------ | +| `Button` | Action | `button` | 8 size variants, icon-only mode | Stable | +| `ButtonGroup` | Action | — | Segmented button container | Stable | +| `Toggle` | Action | `toggle` | Pressable on/off button | Stable | +| `ToggleGroup` | Action | `toggle-group` | Exclusive/multi-select toggle set | Stable | +| `Input` | Form | `input` | — | Stable | +| `InputGroup` | Form | — | Leading/trailing adornments | Stable | +| `Field` | Form | — | Label + input + error wrapper | Stable | +| `Label` | Form | `label` | — | Stable | +| `Select` | Form | `select` | — | Stable | +| `Combobox` | Form | `command` | Searchable select | Stable | +| `Checkbox` | Form | `checkbox` | — | Stable | +| `RadioGroup` | Form | `radio-group` | — | Stable | +| `Switch` | Form | `switch` | — | Stable | +| `Textarea` | Form | `textarea` | — | Stable | +| `Calendar` | Form | `calendar` | — | Stable | +| `InputOtp` | Form | `input-otp` | — | Stable | +| `Slider` | Form | `slider` | — | Stable | +| `CodeEditor` | Form | — | Monaco editor, theme-aware | Stable | +| `Dialog` | Overlay | `dialog` | — | Stable | +| `AlertDialog` | Overlay | `alert-dialog` | — | Stable | +| `Sheet` | Overlay | `sheet` | — | Stable | +| `Drawer` | Overlay | `drawer` | — | Stable | +| `Tooltip` | Overlay | `tooltip` | — | Stable | +| `HoverCard` | Overlay | `hover-card` | — | Stable | +| `Command` | Overlay | `command` | Command palette / search | Stable | +| `DropdownMenu` | Navigation | `dropdown-menu` | — | Stable | +| `ContextMenu` | Navigation | `context-menu` | — | Stable | +| `Menubar` | Navigation | `menubar` | — | Stable | +| `NavigationMenu` | Navigation | `navigation-menu` | — | Stable | +| `Breadcrumb` | Navigation | `breadcrumb` | — | Stable | +| `Tabs` | Navigation | `tabs` | — | Stable | +| `Sidebar` | Navigation | `sidebar` | App-level sidebar pattern | Stable | +| `Alert` | Feedback | `alert` | — | Stable | +| `Badge` | Feedback | `badge` | — | Stable | +| `Skeleton` | Feedback | `skeleton` | — | Stable | +| `Spinner` | Feedback | — | Loading indicator | Stable | +| `Sonner` | Feedback | `sonner` | Toast notifications | Stable | +| `Table` | Data Display | `table` | — | Stable | | `DataTable` | Data Display | — | TanStack Table wrapper, pagination, column toggle | In Dev | -| `Card` | Data Display | `card` | — | Stable | -| `Avatar` | Data Display | `avatar` | — | Stable | -| `Accordion` | Data Display | `accordion` | — | Stable | -| `Collapsible` | Data Display | `collapsible` | — | Stable | -| `Carousel` | Data Display | `carousel` | — | Stable | -| `Item` | Data Display | — | Generic list/menu item | Stable | -| `Kbd` | Data Display | — | Keyboard shortcut indicator | Stable | -| `TetraScienceIcon` | Data Display | — | Brand icon component | Stable | -| `ScrollArea` | Layout | `scroll-area` | — | Stable | -| `Resizable` | Layout | `resizable` | — | Stable | -| `Separator` | Layout | `separator` | — | Stable | -| `AspectRatio` | Layout | `aspect-ratio` | — | Stable | -| `AppLayout` | Composed | — | Full app shell with sidebar | Stable | -| `AppHeader` | Composed | — | Top nav with avatar/actions | Stable | -| `Main` | Composed | — | Main content area with navbar, sidebar, tab bar | Stable | -| `Navbar` | Composed | — | Secondary nav bar | Stable | -| `Sidebar` (composed) | Composed | — | App sidebar with navigation sections | Stable | -| `LaunchContent` | Composed | — | Launch/welcome content panel | Stable | -| `ProtocolConfiguration` | Composed | — | Protocol config form | Stable | -| `ProtocolYamlCard` | Composed | — | YAML protocol display card | Stable | -| `AssistantModal` | Composed | `dialog` | AI assistant chat modal | Beta | -| `CodeScriptEditorButton` | Composed | — | Button that opens code/script editor | Stable | -| `PythonEditorModal` | Composed | `dialog` + CodeEditor | Python script editor | Stable | -| `TdpSearch` | Composed | `command` | TetraScience data platform search | Stable | -| `TdpLink` | Composed | — | TDP-aware link component | Stable | -| `AreaGraph` | Chart | — | Plotly area chart | Stable | -| `BarGraph` | Chart | — | Plotly bar chart (grouped/stacked) | Stable | -| `LineGraph` | Chart | — | Plotly line chart | Stable | -| `ScatterGraph` | Chart | — | Plotly scatter | Stable | -| `Histogram` | Chart | — | Plotly histogram | Stable | -| `PieChart` | Chart | — | Plotly pie | Stable | -| `Heatmap` | Chart | — | Plotly heatmap | Stable | -| `Boxplot` | Chart | — | Plotly box plot | Stable | -| `DotPlot` | Chart | — | Plotly dot plot | Stable | -| `Chromatogram` | Chart | — | Specialized lab chromatogram (themed) | Stable | -| `ChromatogramChart` | Chart | — | Legacy chromatogram (non-themed) | Stable | -| `PlateMap` | Chart | — | 96/384-well plate visualization | Stable | -| `InteractiveScatter` | Chart | — | Zoomable scatter with selection | Stable | +| `Card` | Data Display | `card` | — | Stable | +| `Avatar` | Data Display | `avatar` | — | Stable | +| `Accordion` | Data Display | `accordion` | — | Stable | +| `Collapsible` | Data Display | `collapsible` | — | Stable | +| `Carousel` | Data Display | `carousel` | — | Stable | +| `Item` | Data Display | — | Generic list/menu item | Stable | +| `Kbd` | Data Display | — | Keyboard shortcut indicator | Stable | +| `TetraScienceIcon` | Data Display | — | Brand icon component | Stable | +| `ScrollArea` | Layout | `scroll-area` | — | Stable | +| `Resizable` | Layout | `resizable` | — | Stable | +| `Separator` | Layout | `separator` | — | Stable | +| `AspectRatio` | Layout | `aspect-ratio` | — | Stable | +| `AppLayout` | Composed | — | Full app shell with sidebar | Stable | +| `AppHeader` | Composed | — | Top nav with avatar/actions | Stable | +| `Main` | Composed | — | Main content area with navbar, sidebar, tab bar | Stable | +| `Navbar` | Composed | — | Secondary nav bar | Stable | +| `ProcessFlow` | Composed | — | Controlled multi-step workflow visualizer | Stable | +| `Sidebar` (composed) | Composed | — | App sidebar with navigation sections | Stable | +| `LaunchContent` | Composed | — | Launch/welcome content panel | Stable | +| `ProtocolConfiguration` | Composed | — | Protocol config form | Stable | +| `ProtocolYamlCard` | Composed | — | YAML protocol display card | Stable | +| `AssistantModal` | Composed | `dialog` | AI assistant chat modal | Beta | +| `CodeScriptEditorButton` | Composed | — | Button that opens code/script editor | Stable | +| `PythonEditorModal` | Composed | `dialog` + CodeEditor | Python script editor | Stable | +| `TdpSearch` | Composed | `command` | TetraScience data platform search | Stable | +| `TdpLink` | Composed | — | TDP-aware link component | Stable | +| `AreaGraph` | Chart | — | Plotly area chart | Stable | +| `BarGraph` | Chart | — | Plotly bar chart (grouped/stacked) | Stable | +| `LineGraph` | Chart | — | Plotly line chart | Stable | +| `ScatterGraph` | Chart | — | Plotly scatter | Stable | +| `Histogram` | Chart | — | Plotly histogram | Stable | +| `PieChart` | Chart | — | Plotly pie | Stable | +| `Heatmap` | Chart | — | Plotly heatmap | Stable | +| `Boxplot` | Chart | — | Plotly box plot | Stable | +| `DotPlot` | Chart | — | Plotly dot plot | Stable | +| `Chromatogram` | Chart | — | Specialized lab chromatogram (themed) | Stable | +| `ChromatogramChart` | Chart | — | Legacy chromatogram (non-themed) | Stable | +| `PlateMap` | Chart | — | 96/384-well plate visualization | Stable | +| `InteractiveScatter` | Chart | — | Zoomable scatter with selection | Stable | --- diff --git a/README.md b/README.md index 83e86fcd..a2221bf5 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ This library provides: ## Compatibility | Library version | React | Node.js | TDP (server utilities) | -| --- | --- | --- | --- | -| v0.5.x | 19+ | 18+ | v4.x+ | -| v0.4.x | 19+ | 18+ | v4.x+ | +| --------------- | ----- | ------- | ---------------------- | +| v0.5.x | 19+ | 18+ | v4.x+ | +| v0.4.x | 19+ | 18+ | v4.x+ | > **Note:** The client-side components have no TDP version dependency. > The `/server` utilities (JWT auth, provider helpers) require a running TDP instance of v4.x or later. @@ -43,10 +43,10 @@ yarn add @tetrascience-npm/tetrascience-react-ui ```tsx // 1. Import the CSS once at your app root (required) -import '@tetrascience-npm/tetrascience-react-ui/index.css'; +import "@tetrascience-npm/tetrascience-react-ui/index.css"; // 2. Import components -import { Button, Card, CardHeader, CardContent } from '@tetrascience-npm/tetrascience-react-ui'; +import { Button, Card, CardHeader, CardContent } from "@tetrascience-npm/tetrascience-react-ui"; function App() { return ( @@ -67,9 +67,9 @@ This library uses **Tailwind CSS 4** with design tokens defined as CSS custom pr ### CSS Import Options -| Import path | Use case | -| --- | --- | -| `@tetrascience-npm/tetrascience-react-ui/index.css` | **Pre-built CSS** — use this for most apps. Import once at your app root. | +| Import path | Use case | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| `@tetrascience-npm/tetrascience-react-ui/index.css` | **Pre-built CSS** — use this for most apps. Import once at your app root. | | `@tetrascience-npm/tetrascience-react-ui/index.tailwind.css` | **Tailwind source** — for apps that run their own Tailwind build and want to extend/override tokens. | Most consumers only need `index.css`: @@ -100,7 +100,54 @@ Accordion, Alert, AlertDialog, AspectRatio, Avatar, Badge, Breadcrumb, Button, B TetraScience-specific compositions built from UI primitives: -AppHeader, AppLayout, AssistantModal, CodeScriptEditorButton, LaunchContent, Main, Navbar, ProtocolConfiguration, ProtocolYamlCard, PythonEditorModal, Sidebar, TdpLink, TdpSearch, TdpUrl +AppHeader, AppLayout, AssistantModal, CodeScriptEditorButton, LaunchContent, Main, Navbar, ProcessFlow, ProtocolConfiguration, ProtocolYamlCard, PythonEditorModal, Sidebar, TdpLink, TdpSearch, TdpUrl + +#### ProcessFlow + +Use `ProcessFlow` to render parent-owned multi-step workflow state such as uploads, validation pipelines, review flows, processing stages, and setup sequences. Import it from the package and keep all workflow transitions and side effects in the consuming app. + +```tsx +import { + PROCESS_FLOW_STEP_STATUSES, + ProcessFlow, + type ProcessFlowStep, + type ProcessFlowStepStatus, +} from "@tetrascience-npm/tetrascience-react-ui"; + +const steps: ProcessFlowStep[] = [ + { id: "upload", label: "Upload", status: "completed" }, + { id: "validate", label: "Validate", status: "active" }, + { id: "publish", label: "Publish", status: "pending" }, +]; + +function WorkflowProgress() { + return ( + { + console.log(step.id, details.status); + }} + /> + ); +} + +const allStatuses: readonly ProcessFlowStepStatus[] = PROCESS_FLOW_STEP_STATUSES; +``` + +Expected contract: + +- `status` is independently controlled per step: `pending`, `active`, `completed`, `error`, or `disabled`. +- `selectedStepId` means the step the user is viewing or has clicked; it is separate from the `active` workflow state. +- `onStepSelect` emits user selection only. It does not mean a workflow step completed. +- Parent workflow code owns completion, error handling, retries, analytics, and other side effects. +- Use `connections` and per-step `position` only for simple branching/configurable flows. + +For AI-assisted consuming apps, add a short instruction like this to the app's `AGENTS.md` or `CLAUDE.md`: + +```md +Use `ProcessFlow` from `@tetrascience-npm/tetrascience-react-ui` for multi-step workflow visualization. Do not build a custom stepper for upload, validation, review, approval, processing, or setup flows. Parent components own the workflow state and pass `steps: ProcessFlowStep[]`; each step status must be one of `PROCESS_FLOW_STEP_STATUSES`. Use `selectedStepId` only for the viewed/selected step. Keep completion/error side effects in the parent workflow code, not inside `ProcessFlow`. +``` ### Charts (`charts/`) @@ -117,7 +164,7 @@ Beyond UI components, this library includes server-side helper functions for bui **JWT Token Manager** - Manages JWT token retrieval for data apps: ```typescript -import { jwtManager } from '@tetrascience-npm/tetrascience-react-ui/server'; +import { jwtManager } from "@tetrascience-npm/tetrascience-react-ui/server"; // In Express middleware app.use(async (req, res, next) => { @@ -146,12 +193,8 @@ TypeScript equivalents of the Python helpers from `ts-lib-ui-kit-streamlit` for **Getting Provider Configurations:** ```typescript -import { TDPClient } from '@tetrascience-npm/ts-connectors-sdk'; -import { - getProviderConfigurations, - buildProvider, - jwtManager, -} from '@tetrascience-npm/tetrascience-react-ui/server'; +import { TDPClient } from "@tetrascience-npm/ts-connectors-sdk"; +import { getProviderConfigurations, buildProvider, jwtManager } from "@tetrascience-npm/tetrascience-react-ui/server"; // Get user's auth token from request (e.g., in Express middleware) const userToken = await jwtManager.getTokenFromExpressRequest(req); @@ -160,7 +203,7 @@ const userToken = await jwtManager.getTokenFromExpressRequest(req); // Other fields (tdpEndpoint, connectorId, orgSlug) are read from environment variables const client = new TDPClient({ authToken: userToken, - artifactType: 'data-app', + artifactType: "data-app", }); await client.init(); @@ -172,7 +215,7 @@ for (const config of providers) { // Build a database connection from the config const provider = await buildProvider(config); - const results = await provider.query('SELECT * FROM my_table LIMIT 10'); + const results = await provider.query("SELECT * FROM my_table LIMIT 10"); await provider.close(); } ``` @@ -185,21 +228,21 @@ import { buildDatabricksProvider, getTdpAthenaProvider, type ProviderConfiguration, -} from '@tetrascience-npm/tetrascience-react-ui/server'; +} from "@tetrascience-npm/tetrascience-react-ui/server"; // Snowflake const snowflakeProvider = await buildSnowflakeProvider(config); -const data = await snowflakeProvider.query('SELECT * FROM users'); +const data = await snowflakeProvider.query("SELECT * FROM users"); await snowflakeProvider.close(); // Databricks const databricksProvider = await buildDatabricksProvider(config); -const data = await databricksProvider.query('SELECT * FROM events'); +const data = await databricksProvider.query("SELECT * FROM events"); await databricksProvider.close(); // TDP Athena (uses environment configuration) const athenaProvider = await getTdpAthenaProvider(); -const data = await athenaProvider.query('SELECT * FROM files'); +const data = await athenaProvider.query("SELECT * FROM files"); await athenaProvider.close(); ``` @@ -211,15 +254,15 @@ import { MissingTableError, ProviderConnectionError, InvalidProviderConfigurationError, -} from '@tetrascience-npm/tetrascience-react-ui/server'; +} from "@tetrascience-npm/tetrascience-react-ui/server"; try { - const results = await provider.query('SELECT * FROM missing_table'); + const results = await provider.query("SELECT * FROM missing_table"); } catch (error) { if (error instanceof MissingTableError) { - console.error('Table not found:', error.message); + console.error("Table not found:", error.message); } else if (error instanceof QueryError) { - console.error('Query failed:', error.message); + console.error("Query failed:", error.message); } } ``` @@ -242,20 +285,20 @@ The TDP connector key/value store lets data apps persist small pieces of state ( **Reading and writing values with the user's JWT token:** ```typescript -import { TDPClient } from '@tetrascience-npm/ts-connectors-sdk'; -import { jwtManager } from '@tetrascience-npm/tetrascience-react-ui/server'; +import { TDPClient } from "@tetrascience-npm/ts-connectors-sdk"; +import { jwtManager } from "@tetrascience-npm/tetrascience-react-ui/server"; // In an Express route handler: -app.get('/api/kv/:key', async (req, res) => { +app.get("/api/kv/:key", async (req, res) => { // 1. Get the user's JWT from request cookies const userToken = await jwtManager.getTokenFromExpressRequest(req); - if (!userToken) return res.status(401).json({ error: 'Not authenticated' }); + if (!userToken) return res.status(401).json({ error: "Not authenticated" }); // 2. Create a TDPClient authenticated as the user // (CONNECTOR_ID, TDP_ENDPOINT, ORG_SLUG are read from env vars) const client = new TDPClient({ authToken: userToken, - artifactType: 'data-app', + artifactType: "data-app", }); await client.init(); @@ -264,13 +307,13 @@ app.get('/api/kv/:key', async (req, res) => { res.json({ key: req.params.key, value }); }); -app.put('/api/kv/:key', async (req, res) => { +app.put("/api/kv/:key", async (req, res) => { const userToken = await jwtManager.getTokenFromExpressRequest(req); - if (!userToken) return res.status(401).json({ error: 'Not authenticated' }); + if (!userToken) return res.status(401).json({ error: "Not authenticated" }); const client = new TDPClient({ authToken: userToken, - artifactType: 'data-app', + artifactType: "data-app", }); await client.init(); @@ -283,7 +326,7 @@ app.put('/api/kv/:key', async (req, res) => { **Reading multiple values at once:** ```typescript -const values = await client.getValues(['theme', 'locale', 'last-run']); +const values = await client.getValues(["theme", "locale", "last-run"]); // values[0] → theme, values[1] → locale, values[2] → last-run ``` @@ -315,8 +358,8 @@ Frontend: use `` with default `apiEndpoint="/api/sear Full TypeScript support with exported types: ```tsx -import { Button } from '@tetrascience-npm/tetrascience-react-ui'; -import type { ButtonProps, BarGraphProps, BarDataSeries } from '@tetrascience-npm/tetrascience-react-ui'; +import { Button } from "@tetrascience-npm/tetrascience-react-ui"; +import type { ButtonProps, BarGraphProps, BarDataSeries } from "@tetrascience-npm/tetrascience-react-ui"; ``` ## Examples diff --git a/src/components/composed/ProcessFlow/ProcessFlow.stories.tsx b/src/components/composed/ProcessFlow/ProcessFlow.stories.tsx new file mode 100644 index 00000000..96d2e882 --- /dev/null +++ b/src/components/composed/ProcessFlow/ProcessFlow.stories.tsx @@ -0,0 +1,648 @@ +import { RotateCcwIcon } from "lucide-react"; +import { useEffect, useMemo, useState, type ComponentProps } from "react"; +import { expect, fn, userEvent, within } from "storybook/test"; + +import { + PROCESS_FLOW_STEP_STATUSES, + ProcessFlow, + type ProcessFlowConnection, + type ProcessFlowStep, + type ProcessFlowStepStatus, +} from "./ProcessFlow"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; + +const uploadSteps: ProcessFlowStep[] = [ + { + id: "select-files", + label: "Select files", + description: "Choose source data", + status: "completed", + ariaLabel: "View file selection step", + }, + { + id: "validate-inputs", + label: "Validate inputs", + description: "Check schema and lineage", + status: "active", + ariaLabel: "View validation step", + }, + { + id: "run-job", + label: "Run job", + description: "Process records", + status: "pending", + ariaLabel: "View processing step", + }, + { + id: "publish-results", + label: "Publish results", + description: "Send downstream", + status: "disabled", + ariaLabel: "View publishing step", + }, +]; + +const reviewSteps: ProcessFlowStep[] = [ + { + id: "draft", + label: "Draft", + description: "Prepare request", + status: "completed", + }, + { + id: "review", + label: "Review", + description: "Confirm changes", + status: "error", + }, + { + id: "approval", + label: "Approval", + description: "Await owner signoff", + status: "disabled", + }, +]; + +const branchingSteps: ProcessFlowStep[] = [ + { + id: "upload", + label: "Upload", + description: "Receive files", + status: "completed", + position: { row: 1, column: 0 }, + }, + { + id: "metadata", + label: "Metadata", + description: "Extract context", + status: "completed", + position: { row: 0, column: 1 }, + }, + { + id: "assay", + label: "Assay data", + description: "Normalize rows", + status: "active", + position: { row: 2, column: 1 }, + }, + { + id: "review", + label: "Review", + description: "Inspect output", + status: "pending", + position: { row: 1, column: 2 }, + }, + { + id: "publish", + label: "Publish", + description: "Write records", + status: "pending", + position: { row: 1, column: 3 }, + }, +]; + +const branchingConnections: ProcessFlowConnection[] = [ + { from: "upload", to: "metadata", status: "completed" }, + { from: "upload", to: "assay", status: "active" }, + { from: "metadata", to: "review" }, + { from: "assay", to: "review" }, + { from: "review", to: "publish" }, +]; + +const longWorkflowSteps: ProcessFlowStep[] = [ + { + id: "upload", + label: "Upload", + description: "Receive raw files", + status: "completed", + }, + { + id: "scan", + label: "Scan", + description: "Inspect manifests", + status: "completed", + }, + { + id: "map", + label: "Map fields", + description: "Map columns to schema", + status: "completed", + }, + { + id: "normalize", + label: "Normalize", + description: "Standardize values", + status: "active", + }, + { + id: "validate", + label: "Validate", + description: "Check rules", + status: "pending", + }, + { + id: "review", + label: "Review", + description: "Inspect output", + status: "pending", + }, + { + id: "approve", + label: "Approve", + description: "Confirm release", + status: "pending", + }, + { + id: "publish", + label: "Publish", + description: "Send downstream", + status: "disabled", + }, +]; + +const statusOptionLabels: Record = { + pending: "Pending", + active: "Active", + completed: "Completed", + error: "Error", + disabled: "Disabled", +}; + +function getStepLabelText(step: ProcessFlowStep) { + return typeof step.label === "string" ? step.label : step.id; +} + +function getStepsFromStatusByStepId(steps: ProcessFlowStep[], statusByStepId: Record) { + return steps.map((step) => ({ + ...step, + status: statusByStepId[step.id] ?? step.status, + })); +} + +function expectHorizontalMarkersAligned(canvasElement: HTMLElement) { + const items = [...canvasElement.querySelectorAll("[data-slot='process-flow-item']")] as HTMLElement[]; + const markers = [...canvasElement.querySelectorAll("[data-slot='process-flow-marker']")] as HTMLElement[]; + + expect(markers).toHaveLength(items.length); + + markers.forEach((marker, index) => { + const itemRect = items[index].getBoundingClientRect(); + const markerRect = marker.getBoundingClientRect(); + const itemCenter = itemRect.left + itemRect.width / 2; + const markerCenter = markerRect.left + markerRect.width / 2; + + expect(Math.abs(markerCenter - itemCenter)).toBeLessThanOrEqual(1); + }); +} + +function EditableUploadWorkflow({ + steps: initialSteps = uploadSteps, + selectedStepId: selectedStepIdProp, + onStepSelect, + ...props +}: ComponentProps) { + const initialSelectedStepId = selectedStepIdProp ?? initialSteps[1]?.id ?? initialSteps[0]?.id; + const initialStatusByStepId = useMemo( + () => Object.fromEntries(initialSteps.map((step) => [step.id, step.status ?? "pending"])), + [initialSteps], + ); + const [statusByStepId, setStatusByStepId] = useState(initialStatusByStepId); + const [selectedStepId, setSelectedStepId] = useState(initialSelectedStepId); + const steps = useMemo(() => getStepsFromStatusByStepId(initialSteps, statusByStepId), [initialSteps, statusByStepId]); + + useEffect(() => { + setStatusByStepId(initialStatusByStepId); + }, [initialStatusByStepId]); + + useEffect(() => { + setSelectedStepId(initialSelectedStepId); + }, [initialSelectedStepId]); + + return ( +
+ { + setSelectedStepId(step.id); + onStepSelect?.(step, details); + }} + /> + + + +
+
+
+ Viewing + { + if (value) { + setSelectedStepId(value); + } + }} + variant="outline" + size="sm" + className="flex-wrap" + aria-label="Selected process step" + > + {initialSteps.map((step) => { + const label = getStepLabelText(step); + + return ( + + {label} + + ); + })} + +
+ + +
+ +
+ {initialSteps.map((step) => { + const label = getStepLabelText(step); + + return ( +
+ {label} + { + if (value) { + setStatusByStepId((current) => ({ + ...current, + [step.id]: value as ProcessFlowStepStatus, + })); + } + }} + variant="outline" + size="sm" + className="flex-wrap justify-start sm:justify-end" + aria-label={`Set status for ${label}`} + > + {PROCESS_FLOW_STEP_STATUSES.map((status) => ( + + {statusOptionLabels[status]} + + ))} + +
+ ); + })} +
+
+
+ ); +} + +function DynamicProcessFlow() { + const [activeIndex, setActiveIndex] = useState(1); + const [selectedStepId, setSelectedStepId] = useState(uploadSteps[1].id); + const [hasError, setHasError] = useState(false); + const steps = useMemo( + () => + uploadSteps.map((step, index) => { + if (hasError && step.id === "validate-inputs") { + return { ...step, status: "error" as const }; + } + + if (hasError && index > activeIndex) { + return { ...step, status: "disabled" as const }; + } + + if (index < activeIndex) { + return { ...step, status: "completed" as const }; + } + + if (index === activeIndex) { + return { ...step, status: "active" as const }; + } + + return { ...step, status: "pending" as const }; + }), + [activeIndex, hasError], + ); + + return ( +
+ setSelectedStepId(step.id)} /> +
+ + +
+
+ ); +} + +const meta: Meta = { + title: "Patterns/ProcessFlow", + component: ProcessFlow, + parameters: { + layout: "centered", + docs: { + description: { + component: + "Use ProcessFlow for visualizing parent-owned multi-step workflow state. Import it from the UI kit, pass a configurable steps array, and set each step status independently with `pending`, `active`, `completed`, `error`, or `disabled`. Keep workflow side effects in the parent; ProcessFlow renders the state and emits user selection through `onStepSelect`.", + }, + }, + }, + argTypes: { + connections: { + control: false, + }, + onStepSelect: { + control: false, + }, + steps: { + control: false, + }, + }, + tags: ["autodocs"], + decorators: [ + (Story, context) => + context.parameters.layout === "fullscreen" ? ( + + ) : ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const UploadWorkflow: Story = { + args: { + steps: uploadSteps, + selectedStepId: "validate-inputs", + onStepSelect: fn(), + }, + render: (args) => , + play: async ({ args, canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Steps render with visual states", async () => { + expect(canvas.getAllByText("Select files")[0]).toBeInTheDocument(); + expect(canvas.getAllByText("Validate inputs")[0]).toBeInTheDocument(); + expect(canvas.getAllByText("Run job")[0]).toBeInTheDocument(); + expect(canvas.getAllByText("Publish results")[0]).toBeInTheDocument(); + expect(canvas.getByRole("button", { name: "View publishing step" })).toBeDisabled(); + }); + + await step("Selectable steps call onStepSelect", async () => { + const fileSelectionStep = canvas.getByRole("button", { name: "View file selection step" }); + + await userEvent.click(fileSelectionStep); + expect(args.onStepSelect).toHaveBeenCalled(); + fileSelectionStep.blur(); + }); + + await step("Story controls can change step status and selected step", async () => { + await userEvent.click(canvas.getByRole("radio", { name: "Set Run job to Error" })); + expect( + canvas.getByRole("button", { name: "View processing step" }).closest("[data-status='error']"), + ).toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("radio", { name: "View Run job" })); + expect( + canvas.getByRole("button", { name: "View processing step" }).closest("[data-selected='true']"), + ).toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("button", { name: "Reset" })); + expect( + canvas.getByRole("button", { name: "View processing step" }).closest("[data-status='pending']"), + ).toBeInTheDocument(); + }); + }, +}; + +export const ReviewNeedsAttention: Story = { + args: { + steps: reviewSteps, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Error and disabled states render", async () => { + expect(canvas.getByText("Review")).toBeInTheDocument(); + expect(canvas.getByText("Approval").closest("[data-status='disabled']")).toBeInTheDocument(); + expect(canvas.getByText("Review").closest("[data-status='error']")).toBeInTheDocument(); + }); + }, +}; + +export const DynamicState: Story = { + render: () => , + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Parent state advances the active step", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Advance" })); + expect(canvas.getByText("Run job").closest("[aria-current='step']")).toBeInTheDocument(); + }); + + await step("Parent state can mark a step as error", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Flag validation" })); + expect(canvas.getByText("Validate inputs").closest("[data-status='error']")).toBeInTheDocument(); + }); + }, +}; + +export const VerticalWorkflow: Story = { + args: { + steps: uploadSteps, + selectedStepId: "validate-inputs", + orientation: "vertical", + }, + render: (args) => , + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Vertical flow renders without boxed steps", async () => { + expect(canvas.getAllByText("Select files")[0]).toBeInTheDocument(); + expect(canvas.getAllByText("Validate inputs")[0]).toBeInTheDocument(); + expect(canvasElement.querySelector("[data-orientation='vertical']")).toBeInTheDocument(); + }); + }, +}; + +export const EightStepWorkflow: Story = { + parameters: { + layout: "fullscreen", + }, + args: { + steps: longWorkflowSteps, + selectedStepId: "normalize", + }, + render: (args) => ( +
+
+ +
+
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Eight step workflow renders all steps", async () => { + expect(canvas.getByText("Upload")).toBeInTheDocument(); + expect(canvas.getByText("Scan")).toBeInTheDocument(); + expect(canvas.getByText("Map fields")).toBeInTheDocument(); + expect(canvas.getByText("Normalize")).toBeInTheDocument(); + expect(canvas.getByText("Validate")).toBeInTheDocument(); + expect(canvas.getByText("Review")).toBeInTheDocument(); + expect(canvas.getByText("Approve")).toBeInTheDocument(); + expect(canvas.getByText("Publish")).toBeInTheDocument(); + }); + }, +}; + +export const ResponsiveLongWorkflow: Story = { + parameters: { + layout: "fullscreen", + }, + render: () => ( +
+
+ +
+
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Eight steps start compression inside a 1024px container", async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + + const viewport = canvasElement.querySelector("[data-slot='process-flow-viewport']") as HTMLElement; + const list = canvasElement.querySelector("[data-slot='process-flow-list']") as HTMLElement; + const label = canvas.getByText("Map fields"); + const autoDescription = canvas.getByText("Map columns to schema"); + + expect(list.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth); + expect(getComputedStyle(label).display).not.toBe("none"); + expect(Number.parseFloat(getComputedStyle(label).fontSize)).toBeGreaterThanOrEqual(13); + expect(getComputedStyle(autoDescription).display).toBe("none"); + expectHorizontalMarkersAligned(canvasElement); + }); + }, +}; + +export const SqueezedLongWorkflow: Story = { + parameters: { + layout: "fullscreen", + }, + render: () => ( +
+
+ +
+
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Eight steps continue squeezing before micro mode", async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + + const viewport = canvasElement.querySelector("[data-slot='process-flow-viewport']") as HTMLElement; + const list = canvasElement.querySelector("[data-slot='process-flow-list']") as HTMLElement; + const label = canvas.getByText("Map fields"); + const text = label.closest("[data-slot='process-flow-text']") as HTMLElement; + + expect(list.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth); + expect(getComputedStyle(text).display).not.toBe("none"); + expect(Number.parseFloat(getComputedStyle(label).fontSize)).toBeLessThanOrEqual(12); + expectHorizontalMarkersAligned(canvasElement); + }); + }, +}; + +export const MiniLongWorkflow: Story = { + parameters: { + layout: "fullscreen", + }, + render: () => ( +
+
+ +
+
+ ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Eight steps collapse to a mini rail in very narrow containers", async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + + const viewport = canvasElement.querySelector("[data-slot='process-flow-viewport']") as HTMLElement; + const list = canvasElement.querySelector("[data-slot='process-flow-list']") as HTMLElement; + const label = canvas.getByText("Map fields"); + const text = label.closest("[data-slot='process-flow-text']") as HTMLElement; + + expect(list.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth); + expect(list.scrollWidth).toBeLessThanOrEqual(420); + expect(getComputedStyle(text).display).toBe("none"); + expectHorizontalMarkersAligned(canvasElement); + }); + }, +}; + +export const BranchingWorkflow: Story = { + args: { + steps: branchingSteps, + connections: branchingConnections, + selectedStepId: "assay", + size: "compact", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Branching steps and connections render", async () => { + expect(canvas.getByText("Metadata")).toBeInTheDocument(); + expect(canvas.getByText("Assay data")).toBeInTheDocument(); + expect( + canvasElement.querySelectorAll("[data-slot='process-flow-canvas'] > svg > path[data-status]"), + ).toHaveLength(branchingConnections.length); + }); + }, +}; diff --git a/src/components/composed/ProcessFlow/ProcessFlow.test.tsx b/src/components/composed/ProcessFlow/ProcessFlow.test.tsx new file mode 100644 index 00000000..83c128ff --- /dev/null +++ b/src/components/composed/ProcessFlow/ProcessFlow.test.tsx @@ -0,0 +1,198 @@ +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + PROCESS_FLOW_STEP_STATUSES, + ProcessFlow, + type ProcessFlowConnection, + type ProcessFlowStep, +} from "./ProcessFlow"; + +import type { ReactElement } from "react"; + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); +}); + +function render(ui: ReactElement) { + flushSync(() => root.render(ui)); + return container; +} + +function getButtonByText(text: string) { + const button = [...container.querySelectorAll("button")].find((element) => element.textContent?.includes(text)); + + expect(button).toBeDefined(); + + return button as HTMLButtonElement; +} + +const steps: ProcessFlowStep[] = [ + { + id: "upload", + label: "Upload", + description: "Receive data", + status: "completed", + }, + { + id: "validate", + label: "Validate", + description: "Check schema", + status: "active", + }, + { + id: "publish", + label: "Publish", + description: "Write records", + status: "disabled", + }, +]; + +describe("ProcessFlow", () => { + it("exports the shared step status values", () => { + expect(PROCESS_FLOW_STEP_STATUSES).toEqual(["pending", "active", "completed", "error", "disabled"]); + }); + + it("renders steps with externally provided statuses", () => { + render(); + + expect(container.querySelector("[data-slot='process-flow']")).toBeTruthy(); + expect(container.textContent).toContain("Upload"); + expect(container.textContent).toContain("Validate"); + expect(container.textContent).toContain("Publish"); + expect(container.querySelector("[data-status='completed']")).toBeTruthy(); + expect(container.querySelector("[data-status='active']")).toBeTruthy(); + expect(container.querySelector("[data-status='disabled']")).toBeTruthy(); + expect(container.querySelector("[aria-current='step']")?.textContent).toContain("Validate"); + expect(container.querySelector("[data-selected='true']")?.textContent).toContain("Validate"); + }); + + it("keeps disabled step markers opaque so rails do not show through", () => { + render(); + + const disabledStep = container.querySelector("[data-status='disabled']"); + + expect(disabledStep?.className).not.toContain("opacity-"); + }); + + it("reflects parent-owned state changes on rerender", () => { + render(); + expect(container.querySelector("[aria-current='step']")?.textContent).toContain("Validate"); + + const updatedSteps: ProcessFlowStep[] = steps.map((step) => { + if (step.id === "validate") { + return { ...step, status: "completed" }; + } + + if (step.id === "publish") { + return { ...step, status: "active", disabled: false }; + } + + return step; + }); + + render(); + + expect(container.querySelector("[aria-current='step']")?.textContent).toContain("Publish"); + expect(container.querySelector("[data-selected='true']")?.textContent).toContain("Publish"); + }); + + it("uses responsive density defaults for horizontal flows", () => { + render(); + + const viewport = container.querySelector("[data-slot='process-flow-viewport']") as HTMLElement; + const description = container.querySelector("[data-slot='process-flow-description']"); + + expect(viewport.style.getPropertyValue("--process-flow-step-min-width-base")).toBe("11rem"); + expect(viewport.style.getPropertyValue("--process-flow-step-min-width-responsive")).toBe("8rem"); + expect(viewport.style.getPropertyValue("--process-flow-step-min-width-squeezed")).toBe("5.5rem"); + expect(viewport.style.getPropertyValue("--process-flow-step-min-width-mini")).toBe("2.75rem"); + expect(viewport.style.getPropertyValue("--process-flow-marker-size-base")).toBe("2.5rem"); + expect(viewport.style.getPropertyValue("--process-flow-marker-size-responsive")).toBe("2rem"); + expect(viewport.style.getPropertyValue("--process-flow-marker-size-squeezed")).toBe("1.5rem"); + expect(viewport.style.getPropertyValue("--process-flow-marker-size-mini")).toBe("1.25rem"); + expect(description?.getAttribute("data-description-visibility")).toBe("auto"); + }); + + it("respects explicit description visibility", () => { + render(); + + expect( + container.querySelector("[data-slot='process-flow-description']")?.getAttribute("data-description-visibility"), + ).toBe("visible"); + + render(); + + expect(container.querySelector("[data-slot='process-flow-description']")).toBeNull(); + }); + + it("calls onStepSelect for selectable steps", () => { + const onStepSelect = vi.fn(); + + render(); + + getButtonByText("Upload").click(); + + expect(onStepSelect).toHaveBeenCalledTimes(1); + expect(onStepSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "upload" }), + expect.objectContaining({ status: "completed", stepIndex: 0 }), + ); + }); + + it("does not call onStepSelect for disabled steps", () => { + const onStepSelect = vi.fn(); + + render(); + + getButtonByText("Publish").click(); + + expect(onStepSelect).not.toHaveBeenCalled(); + }); + + it("renders configured branching connections", () => { + const branchSteps: ProcessFlowStep[] = [ + { + id: "ingest", + label: "Ingest", + status: "completed", + position: { row: 1, column: 0 }, + }, + { + id: "metadata", + label: "Metadata", + status: "completed", + position: { row: 0, column: 1 }, + }, + { + id: "assay", + label: "Assay", + status: "active", + position: { row: 2, column: 1 }, + }, + ]; + const branchConnections: ProcessFlowConnection[] = [ + { from: "ingest", to: "metadata", status: "completed" }, + { from: "ingest", to: "assay", status: "active" }, + ]; + + render(); + + const paths = container.querySelectorAll("[data-slot='process-flow-canvas'] > svg > path[data-status]"); + + expect(paths).toHaveLength(branchConnections.length); + expect(paths[0].getAttribute("data-status")).toBe("completed"); + expect(paths[1].getAttribute("data-status")).toBe("active"); + }); +}); diff --git a/src/components/composed/ProcessFlow/ProcessFlow.tsx b/src/components/composed/ProcessFlow/ProcessFlow.tsx new file mode 100644 index 00000000..4e76952b --- /dev/null +++ b/src/components/composed/ProcessFlow/ProcessFlow.tsx @@ -0,0 +1,897 @@ +import { CheckIcon, DotIcon, LockIcon, TriangleAlertIcon } from "lucide-react"; + +import type { ComponentPropsWithoutRef, CSSProperties, MouseEvent, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +/** + * Runtime list of the visual states supported by ProcessFlow steps. + * Use this for controls, schemas, and forms that need to present the same status values the component understands. + */ +export const PROCESS_FLOW_STEP_STATUSES = ["pending", "active", "completed", "error", "disabled"] as const; + +/** + * Visual state for a ProcessFlow step. + * + * The parent application owns this state. ProcessFlow renders the provided status and does not run workflow side effects. + */ +export type ProcessFlowStepStatus = (typeof PROCESS_FLOW_STEP_STATUSES)[number]; +export type ProcessStepStatus = ProcessFlowStepStatus; + +/** Direction for linear process flows. Branching flows are configured with step positions and connections. */ +export type ProcessFlowOrientation = "horizontal" | "vertical"; + +/** Visual density of the process flow. */ +export type ProcessFlowSize = "default" | "compact"; + +type ProcessFlowLayout = "linear" | "branching"; +type ProcessFlowStepContentLayout = "stacked" | "inline" | "anchored"; +type ProcessFlowDescriptionVisibility = "auto" | "hidden" | "visible"; + +export interface ProcessFlowStepPosition { + /** Zero-based row used when rendering a simple branching/configurable flow. */ + row?: number; + /** Zero-based column used when rendering a simple branching/configurable flow. */ + column?: number; +} + +/** + * Configurable step rendered by ProcessFlow. + * + * Keep workflow-specific behavior in the parent. A step config should describe what to render, not what to do when a + * workflow completes or errors. + */ +export interface ProcessFlowStep { + /** Stable identifier used for selection and connections. */ + id: string; + /** Visible step label. */ + label: ReactNode; + /** Optional secondary text shown under or beside the label. */ + description?: ReactNode; + /** Parent-controlled visual state. Defaults to "pending". */ + status?: ProcessFlowStepStatus; + /** Accessible label for selectable steps. */ + ariaLabel?: string; + /** Forces the step into the disabled visual/non-interactive state. */ + disabled?: boolean; + /** Set to false when the step should render as non-navigable even when onStepSelect is provided. */ + selectable?: boolean; + /** Optional grid position for simple branching/configurable layouts. */ + position?: ProcessFlowStepPosition; +} + +export type ProcessFlowStepConfig = ProcessFlowStep; +export type ProcessStep = ProcessFlowStep; + +/** Connection between two steps in a branching/configurable flow. Linear flows derive connections automatically. */ +export interface ProcessFlowConnection { + id?: string; + from: string; + to: string; + status?: ProcessFlowStepStatus; + ariaLabel?: string; +} + +export interface ProcessFlowStepSelectDetails { + /** Zero-based index of the selected step in the steps prop. */ + stepIndex: number; + /** Current visual status of the selected step. */ + status: ProcessFlowStepStatus; + nativeEvent: MouseEvent; +} + +/** + * Presentational process flow. + * + * ProcessFlow is fully controlled by props. It visualizes parent-owned workflow state and emits user selection through + * onStepSelect. It intentionally does not own status transitions or fire completion/error side effects. + */ +export interface ProcessFlowProps extends Omit, "onSelect"> { + /** Ordered list of steps to render. Each step controls its own visual status. */ + steps: ProcessFlowStep[]; + /** Optional explicit connections for branching/configurable flows. */ + connections?: ProcessFlowConnection[]; + /** Selected/viewed step id. This is separate from the active workflow status. */ + selectedStepId?: string; + /** Called when a selectable step is clicked. Disabled and non-selectable steps do not call this. */ + onStepSelect?: (step: ProcessFlowStep, details: ProcessFlowStepSelectDetails) => void; + orientation?: ProcessFlowOrientation; + size?: ProcessFlowSize; + /** Defaults to true for default size and false for compact size. */ + showDescriptions?: boolean; +} + +interface PositionedStep { + step: ProcessFlowStep; + stepIndex: number; + status: ProcessFlowStepStatus; + row: number; + column: number; +} + +interface ResolvedConnection extends ProcessFlowConnection { + id: string; + fromStep: PositionedStep; + toStep: PositionedStep; + status: ProcessFlowStepStatus; +} + +interface Point { + x: number; + y: number; +} + +interface StepControlClassOptions { + layout: ProcessFlowLayout; + contentLayout: ProcessFlowStepContentLayout; + onStepSelect?: ProcessFlowProps["onStepSelect"]; + isDisabled: boolean; + isSelected: boolean; + size: ProcessFlowSize; + status: ProcessFlowStepStatus; +} + +const STATUS_LABELS: Record = { + pending: "Pending", + active: "Active", + completed: "Completed", + error: "Error", + disabled: "Disabled", +}; + +const LINEAR_STEP_STATUS_CLASSES: Record = { + pending: "text-muted-foreground", + active: "text-foreground", + completed: "text-foreground", + error: "text-destructive", + disabled: "text-muted-foreground", +}; + +const MARKER_STATUS_CLASSES: Record = { + pending: "border-border bg-background text-muted-foreground", + active: "border-primary bg-primary text-primary-foreground", + completed: "border-positive bg-positive text-background", + error: "border-destructive bg-destructive text-background", + disabled: "border-border bg-muted text-muted-foreground", +}; + +const CONNECTION_STATUS_CLASSES: Record = { + pending: "stroke-muted-foreground/35", + active: "stroke-primary", + completed: "stroke-positive", + error: "stroke-destructive", + disabled: "stroke-border", +}; + +const STEP_MIN_WIDTH: Record = { + default: "11rem", + compact: "8.5rem", +}; + +const RESPONSIVE_STEP_MIN_WIDTH: Record = { + default: "8rem", + compact: "7rem", +}; + +const SQUEEZED_STEP_MIN_WIDTH: Record = { + default: "5.5rem", + compact: "4.75rem", +}; + +const MINI_STEP_MIN_WIDTH: Record = { + default: "2.75rem", + compact: "2.5rem", +}; + +const ROW_MIN_HEIGHT: Record = { + default: "7.5rem", + compact: "5.75rem", +}; + +const RESPONSIVE_ROW_MIN_HEIGHT: Record = { + default: "6.5rem", + compact: "5rem", +}; + +const SQUEEZED_ROW_MIN_HEIGHT: Record = { + default: "4.5rem", + compact: "3.75rem", +}; + +const MINI_ROW_MIN_HEIGHT: Record = { + default: "3.5rem", + compact: "3rem", +}; + +const GRID_GAP: Record = { + default: "1.25rem", + compact: "0.75rem", +}; + +const RESPONSIVE_GRID_GAP: Record = { + default: "0.875rem", + compact: "0.625rem", +}; + +const SQUEEZED_GRID_GAP: Record = { + default: "0.5rem", + compact: "0.375rem", +}; + +const MINI_GRID_GAP: Record = { + default: "0.375rem", + compact: "0.25rem", +}; + +const LINEAR_MARKER_SIZE: Record = { + default: "2.5rem", + compact: "1.75rem", +}; + +const RESPONSIVE_LINEAR_MARKER_SIZE: Record = { + default: "2rem", + compact: "1.5rem", +}; + +const SQUEEZED_LINEAR_MARKER_SIZE: Record = { + default: "1.5rem", + compact: "1.25rem", +}; + +const MINI_LINEAR_MARKER_SIZE: Record = { + default: "1.25rem", + compact: "1.125rem", +}; + +function getDescriptionVisibility( + showDescriptions: boolean | undefined, + size: ProcessFlowSize, +): ProcessFlowDescriptionVisibility { + if (showDescriptions === true) { + return "visible"; + } + + if (showDescriptions === false || size === "compact") { + return "hidden"; + } + + return "auto"; +} + +function normalizeGridIndex(value: number | undefined, fallback: number) { + return Math.max(Math.floor(value ?? fallback), 0); +} + +function getStepStatus(step: ProcessFlowStep): ProcessFlowStepStatus { + if (step.disabled) { + return "disabled"; + } + + return step.status ?? "pending"; +} + +function getStepAccessibleLabel(step: ProcessFlowStep, status: ProcessFlowStepStatus, stepNumber: number) { + if (step.ariaLabel) { + return step.ariaLabel; + } + + const label = typeof step.label === "string" ? step.label : `Step ${stepNumber}`; + + return `${label}, ${STATUS_LABELS[status]}`; +} + +function positionStep(step: ProcessFlowStep, index: number, orientation: ProcessFlowOrientation): PositionedStep { + const rowFallback = orientation === "vertical" ? index : 0; + const columnFallback = orientation === "vertical" ? 0 : index; + + return { + step, + stepIndex: index, + status: getStepStatus(step), + row: normalizeGridIndex(step.position?.row, rowFallback), + column: normalizeGridIndex(step.position?.column, columnFallback), + }; +} + +function deriveConnectionStatus( + fromStatus: ProcessFlowStepStatus, + toStatus: ProcessFlowStepStatus, +): ProcessFlowStepStatus { + if (fromStatus === "error" || toStatus === "error") { + return "error"; + } + + if (fromStatus === "disabled" || toStatus === "disabled") { + return "disabled"; + } + + if (fromStatus === "completed" && (toStatus === "completed" || toStatus === "active")) { + return "completed"; + } + + if (fromStatus === "active" || toStatus === "active") { + return "active"; + } + + return "pending"; +} + +function getLinearConnections(steps: PositionedStep[]): ProcessFlowConnection[] { + return steps.slice(0, -1).map((step, index) => ({ + from: step.step.id, + to: steps[index + 1].step.id, + })); +} + +function resolveConnections(steps: PositionedStep[], connections?: ProcessFlowConnection[]) { + const stepMap = new Map(steps.map((step) => [step.step.id, step])); + + return (connections ?? getLinearConnections(steps)).flatMap((connection, index): ResolvedConnection[] => { + const fromStep = stepMap.get(connection.from); + const toStep = stepMap.get(connection.to); + + if (!fromStep || !toStep) { + return []; + } + + return [ + { + ...connection, + id: connection.id ?? `${connection.from}-${connection.to}-${index}`, + fromStep, + toStep, + status: connection.status ?? deriveConnectionStatus(fromStep.status, toStep.status), + }, + ]; + }); +} + +function getPoint(step: PositionedStep, rowCount: number, columnCount: number): Point { + return { + x: ((step.column + 0.5) / columnCount) * 100, + y: ((step.row + 0.5) / rowCount) * 100, + }; +} + +function getConnectionPath(connection: ResolvedConnection, rowCount: number, columnCount: number) { + const start = getPoint(connection.fromStep, rowCount, columnCount); + const end = getPoint(connection.toStep, rowCount, columnCount); + + if (connection.fromStep.row === connection.toStep.row || connection.fromStep.column === connection.toStep.column) { + return `M ${start.x} ${start.y} L ${end.x} ${end.y}`; + } + + const midX = (start.x + end.x) / 2; + + return `M ${start.x} ${start.y} C ${midX} ${start.y}, ${midX} ${end.y}, ${end.x} ${end.y}`; +} + +function getMarkerContent(status: ProcessFlowStepStatus, stepNumber: number) { + if (status === "completed") { + return