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..35654420 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,58 @@ 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", description: "Choose source files", status: "completed" }, + { id: "validate", label: "Validate", description: "Check schema and lineage", status: "active" }, + { id: "publish", label: "Publish", description: "Send downstream", 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. +- `description` is shown by default. Pass `showDescriptions={false}` to hide all descriptions. +- Descriptions auto-hide at narrow container widths (≤40rem) for mobile layouts. +- The component fills 100% of its container width — size it by controlling the container. +- Selected completed steps render with a green label; selected active steps render with a blue label. +- 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 +168,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 +197,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 +207,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 +219,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 +232,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 +258,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 +289,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 +311,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 +330,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 +362,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.component.test.tsx b/src/components/composed/ProcessFlow/ProcessFlow.component.test.tsx new file mode 100644 index 00000000..c2300f53 --- /dev/null +++ b/src/components/composed/ProcessFlow/ProcessFlow.component.test.tsx @@ -0,0 +1,328 @@ +import * as React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { ProcessFlow } from "./ProcessFlow"; + +import type { ProcessFlowStep } from "./ProcessFlow.utils"; + +// Suppress "not configured to support act(...)" warnings in jsdom +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); +}); + +function render(ui: React.ReactElement) { + act(() => { + root.render(ui); + }); +} + +function q(selector: string) { + return container.querySelector(selector); +} + +function qa(selector: string) { + return container.querySelectorAll(selector); +} + +const LINEAR_STEPS: ProcessFlowStep[] = [ + { id: "upload", label: "Upload", status: "completed" }, + { id: "validate", label: "Validate", status: "active" }, + { id: "publish", label: "Publish", status: "pending" }, +]; + +describe("ProcessFlow", () => { + it("renders nothing when steps is empty", () => { + render(); + expect(q('[data-slot="process-flow"]')).toBeNull(); + }); + + it("renders horizontal linear flow by default", () => { + render(); + const nav = q('[data-slot="process-flow"]'); + expect(nav).not.toBeNull(); + expect(nav?.getAttribute("data-orientation")).toBe("horizontal"); + expect(qa('[data-slot="process-flow-item"]')).toHaveLength(3); + }); + + it("renders vertical linear flow", () => { + render(); + expect(q('[data-slot="process-flow"]')?.getAttribute("data-orientation")).toBe("vertical"); + expect(qa('[data-slot="process-flow-item"]')).toHaveLength(3); + }); + + it("uses the provided aria-label", () => { + render(); + expect(q("nav")?.getAttribute("aria-label")).toBe("My workflow"); + }); + + it("applies compact size", () => { + render(); + expect(q('[data-size="compact"]')).not.toBeNull(); + }); + + it("renders a horizontal rail when there is more than one step", () => { + render(); + const list = q('[data-slot="process-flow-list"]'); + // Rail and connection segments are aria-hidden
  • elements + const railItems = list?.querySelectorAll('li[aria-hidden="true"]') ?? []; + expect(railItems.length).toBeGreaterThan(0); + }); + + it("does not render a rail for a single-step horizontal flow", () => { + render(); + const list = q('[data-slot="process-flow-list"]'); + const railItems = list?.querySelectorAll('li[aria-hidden="true"]') ?? []; + expect(railItems).toHaveLength(0); + }); + + it("renders a vertical rail when there is more than one step", () => { + render(); + const list = q('[data-slot="process-flow-list"]'); + const railItems = list?.querySelectorAll('li[aria-hidden="true"]') ?? []; + expect(railItems.length).toBeGreaterThan(0); + }); + + it("does not render a rail for a single-step vertical flow", () => { + render(); + const list = q('[data-slot="process-flow-list"]'); + const railItems = list?.querySelectorAll('li[aria-hidden="true"]') ?? []; + expect(railItems).toHaveLength(0); + }); + + it("renders all five step statuses", () => { + const steps: ProcessFlowStep[] = [ + { id: "p", label: "Pending" }, + { id: "a", label: "Active", status: "active" }, + { id: "c", label: "Completed", status: "completed" }, + { id: "e", label: "Error", status: "error" }, + { id: "d", label: "Disabled", disabled: true }, + ]; + render(); + expect(q('[data-status="pending"]')).not.toBeNull(); + expect(q('[data-status="active"]')).not.toBeNull(); + expect(q('[data-status="completed"]')).not.toBeNull(); + expect(q('[data-status="error"]')).not.toBeNull(); + expect(q('[data-status="disabled"]')).not.toBeNull(); + }); + + it("marks the active step with aria-current=step", () => { + render(); + expect(q('[aria-current="step"]')).not.toBeNull(); + }); + + it("marks error steps with aria-invalid", () => { + render(); + expect(q('[aria-invalid="true"]')).not.toBeNull(); + }); + + it("renders non-interactive divs when onStepSelect is not provided", () => { + render(); + expect(qa("button")).toHaveLength(0); + }); + + it("renders interactive buttons when onStepSelect is provided", () => { + render(); + expect(qa("button")).toHaveLength(3); + }); + + it("calls onStepSelect with step and details on click", () => { + const onStepSelect = vi.fn(); + render(); + act(() => { + (qa("button")[0] as HTMLButtonElement).click(); + }); + expect(onStepSelect).toHaveBeenCalledWith(LINEAR_STEPS[0], expect.objectContaining({ stepIndex: 0, status: "completed" })); + }); + + it("disables the button for disabled steps", () => { + const steps: ProcessFlowStep[] = [{ id: "d", label: "D", disabled: true }, { id: "b", label: "B" }]; + render(); + expect((qa("button")[0] as HTMLButtonElement).disabled).toBe(true); + }); + + it("disables the button when selectable is false", () => { + const steps: ProcessFlowStep[] = [{ id: "a", label: "A", selectable: false }, { id: "b", label: "B" }]; + render(); + expect((qa("button")[0] as HTMLButtonElement).disabled).toBe(true); + }); + + it("marks the selected step with data-selected and aria-pressed", () => { + render(); + const selected = q('[data-selected="true"]'); + expect(selected).not.toBeNull(); + expect(selected?.getAttribute("aria-pressed")).toBe("true"); + }); + + it("shows descriptions by default", () => { + const steps: ProcessFlowStep[] = [ + { id: "a", label: "A", description: "Step A desc", status: "pending" }, + { id: "b", label: "B" }, + ]; + render(); + expect(q('[data-slot="process-flow-description"]')).not.toBeNull(); + }); + + it("hides descriptions when showDescriptions is false", () => { + const steps: ProcessFlowStep[] = [ + { id: "a", label: "A", description: "Hidden desc", status: "pending" }, + { id: "b", label: "B" }, + ]; + render(); + expect(q('[data-slot="process-flow-description"]')).toBeNull(); + }); + + it("renders selected completed step label with positive style (horizontal, linear)", () => { + const steps: ProcessFlowStep[] = [ + { id: "done", label: "Done", status: "completed" }, + { id: "next", label: "Next" }, + ]; + render(); + const label = q('[data-slot="process-flow-label"]'); + expect(label?.className).toContain("text-positive"); + }); + + it("renders selected active step label with primary style (horizontal, linear)", () => { + const steps: ProcessFlowStep[] = [ + { id: "cur", label: "Current", status: "active" }, + { id: "nxt", label: "Next" }, + ]; + render(); + const label = q('[data-slot="process-flow-label"]'); + expect(label?.className).toContain("text-primary"); + }); + + it("renders selected error step label without extra highlight", () => { + const steps: ProcessFlowStep[] = [ + { id: "err", label: "Error", status: "error" }, + { id: "nxt", label: "Next" }, + ]; + render(); + const label = q('[data-slot="process-flow-label"]'); + expect(label?.className).not.toContain("text-positive"); + expect(label?.className).not.toContain("text-primary"); + }); + + it("renders compact size in vertical orientation", () => { + render(); + expect(q('[data-size="compact"]')).not.toBeNull(); + }); + + it("renders error, pending, and disabled connection segments in vertical flow", () => { + // Steps chosen to produce each connection status: error, pending, disabled + const steps: ProcessFlowStep[] = [ + { id: "a", label: "A", status: "error" }, + { id: "b", label: "B", status: "pending" }, + { id: "c", label: "C", status: "completed" }, + { id: "d", label: "D", disabled: true }, + ]; + render(); + expect(qa('[data-slot="process-flow-item"]')).toHaveLength(4); + }); + + describe("branching layout", () => { + const BRANCH_STEPS: ProcessFlowStep[] = [ + { id: "start", label: "Start", position: { column: 0, row: 0 } }, + { id: "branch-a", label: "Branch A", position: { column: 1, row: 0 } }, + { id: "branch-b", label: "Branch B", position: { column: 1, row: 1 } }, + { id: "end", label: "End", position: { column: 2, row: 0 } }, + ]; + + it("renders an SVG canvas with connection paths", () => { + render( + , + ); + expect(q("svg")).not.toBeNull(); + expect(qa("path").length).toBeGreaterThan(0); + }); + + it("renders interactive buttons with anchored layout in branching flow", () => { + render( + , + ); + expect(qa("button").length).toBeGreaterThan(0); + }); + + it("triggers branching layout via hasCustomStepLayout (no explicit connections)", () => { + render(); + expect(q("svg")).not.toBeNull(); + }); + + it("skips connections that reference unknown step ids", () => { + render( + , + ); + const paths = qa("path"); + expect(paths).toHaveLength(1); + }); + + it("renders selected step in branching layout", () => { + render( + , + ); + expect(q('[data-selected="true"]')).not.toBeNull(); + }); + + it("renders disabled step in branching layout", () => { + const steps: ProcessFlowStep[] = [ + { id: "s", label: "S", position: { column: 0, row: 0 } }, + { id: "d", label: "D", disabled: true, position: { column: 1, row: 0 } }, + ]; + render(); + const buttons = qa("button"); + expect((buttons[1] as HTMLButtonElement).disabled).toBe(true); + }); + + it("renders descriptions in branching layout", () => { + const steps: ProcessFlowStep[] = [ + { id: "s", label: "S", description: "branch desc", position: { column: 0, row: 0 } }, + { id: "t", label: "T", position: { column: 1, row: 0 } }, + ]; + render(); + expect(q('[data-slot="process-flow-description"]')).not.toBeNull(); + }); + + it("hides descriptions in branching layout when showDescriptions is false", () => { + const steps: ProcessFlowStep[] = [ + { id: "s", label: "S", description: "hidden", position: { column: 0, row: 0 } }, + { id: "t", label: "T", position: { column: 1, row: 0 } }, + ]; + render(); + expect(q('[data-slot="process-flow-description"]')).toBeNull(); + }); + }); +}); diff --git a/src/components/composed/ProcessFlow/ProcessFlow.stories.tsx b/src/components/composed/ProcessFlow/ProcessFlow.stories.tsx new file mode 100644 index 00000000..4f6c7b28 --- /dev/null +++ b/src/components/composed/ProcessFlow/ProcessFlow.stories.tsx @@ -0,0 +1,744 @@ +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 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 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: "Design Patterns/ProcessFlow", + component: ProcessFlow, + parameters: { + layout: "padded", + 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) => ], +}; + +export default meta; + +type Story = StoryObj; + +export const UploadWorkflow: Story = { + args: { + steps: uploadSteps, + selectedStepId: "validate-inputs", + onStepSelect: fn(), + }, + parameters: { + zephyr: { + testCaseId: "SW-T5313", + }, + }, + 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, + }, + parameters: { + zephyr: { + testCaseId: "SW-T5314", + }, + }, + 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 = { + parameters: { + zephyr: { + testCaseId: "SW-T5315", + }, + }, + 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", + }, + parameters: { + zephyr: { + testCaseId: "SW-T5316", + }, + }, + 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", + zephyr: { + testCaseId: "SW-T5317", + }, + }, + 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", + zephyr: { + testCaseId: "SW-T5318", + }, + }, + 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 description = 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(description).display).not.toBe("none"); + expectHorizontalMarkersAligned(canvasElement); + }); + }, +}; + +export const SqueezedLongWorkflow: Story = { + parameters: { + layout: "fullscreen", + zephyr: { + testCaseId: "SW-T5319", + }, + }, + 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", + zephyr: { + testCaseId: "SW-T5320", + }, + }, + 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; + const description = text.querySelector("[data-slot='process-flow-description']") as HTMLElement; + + expect(list.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth); + expect(getComputedStyle(text).display).not.toBe("none"); + expect(getComputedStyle(description).display).toBe("none"); + expectHorizontalMarkersAligned(canvasElement); + }); + }, +}; + + +export const SingleStep: Story = { + args: { + steps: [{ id: "only", label: "Process", description: "Only step", status: "active" }], + selectedStepId: "only", + }, + parameters: { + zephyr: { + testCaseId: "SW-T5321", + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Single step renders without a connection rail", async () => { + expect(canvas.getByText("Process")).toBeInTheDocument(); + expect(canvasElement.querySelector("[aria-current='step']")).toBeInTheDocument(); + expect(canvasElement.querySelector("[data-slot='process-flow-list'] > li[aria-hidden='true']")).toBeNull(); + }); + }, +}; + +export const SingleStepVertical: Story = { + args: { + steps: [{ id: "only", label: "Process", description: "Only step", status: "active" }], + orientation: "vertical", + }, + parameters: { + zephyr: { + testCaseId: "SW-T5322", + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Single vertical step renders without a connection rail", async () => { + expect(canvas.getByText("Process")).toBeInTheDocument(); + expect(canvasElement.querySelector("[data-orientation='vertical']")).toBeInTheDocument(); + expect(canvasElement.querySelector("[data-slot='process-flow-list'] > li[aria-hidden='true']")).toBeNull(); + }); + }, +}; + +export const CompactVertical: Story = { + args: { + steps: uploadSteps, + selectedStepId: "validate-inputs", + orientation: "vertical", + size: "compact", + onStepSelect: fn(), + }, + parameters: { + zephyr: { + testCaseId: "SW-T5323", + }, + }, + play: async ({ canvasElement, step }) => { + await step("Compact vertical renders with correct marker-size CSS variable", async () => { + expect(canvasElement.querySelector("[data-orientation='vertical']")).toBeInTheDocument(); + const viewport = canvasElement.querySelector("[data-slot='process-flow-viewport']") as HTMLElement; + expect(viewport.style.getPropertyValue("--process-flow-marker-size-base")).toBe("1.75rem"); + }); + }, +}; + +export const DescriptionsAlwaysVisible: Story = { + args: { + steps: uploadSteps, + selectedStepId: "validate-inputs", + showDescriptions: true, + }, + parameters: { + zephyr: { + testCaseId: "SW-T5324", + }, + }, + play: async ({ canvasElement, step }) => { + await step("All descriptions are shown when showDescriptions is true", async () => { + const descs = canvasElement.querySelectorAll("[data-slot='process-flow-description']"); + expect(descs.length).toBeGreaterThan(0); + expect(descs[0].getAttribute("data-description-visibility")).toBe("visible"); + }); + }, +}; + +export const VerticalWithErrorAndPendingConnections: Story = { + args: { + // error→pending="error", pending→pending="pending", pending→active="active" + steps: [ + { id: "a", label: "Upload", status: "error" }, + { id: "b", label: "Validate", status: "pending" }, + { id: "c", label: "Normalize", status: "pending" }, + { id: "d", label: "Publish", status: "active" }, + ], + orientation: "vertical", + }, + parameters: { + zephyr: { + testCaseId: "SW-T5325", + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Vertical flow renders error, pending, and active connection styles", async () => { + expect(canvas.getByText("Upload").closest("[data-status='error']")).toBeInTheDocument(); + expect(canvasElement.querySelector("[data-orientation='vertical']")).toBeInTheDocument(); + const connections = canvasElement.querySelectorAll( + "[data-slot='process-flow-list'] > li[aria-hidden='true']", + ); + expect(connections.length).toBeGreaterThan(0); + }); + }, +}; + +export const NonSelectableStep: Story = { + args: { + steps: [ + { id: "a", label: "Step A", status: "completed" }, + { id: "b", label: "Step B", status: "active", selectable: false }, + { id: "c", label: "Step C", status: "pending" }, + ], + onStepSelect: fn(), + }, + parameters: { + zephyr: { + testCaseId: "SW-T5326", + }, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Step with selectable=false is rendered disabled despite active status", async () => { + expect(canvas.getByRole("button", { name: "Step B, Active" })).toBeDisabled(); + }); + }, +}; diff --git a/src/components/composed/ProcessFlow/ProcessFlow.test.ts b/src/components/composed/ProcessFlow/ProcessFlow.test.ts new file mode 100644 index 00000000..14371e88 --- /dev/null +++ b/src/components/composed/ProcessFlow/ProcessFlow.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { + PROCESS_FLOW_STEP_STATUSES, + deriveConnectionStatus, + getConnectionPath, + getDescriptionVisibility, + getStepAccessibleLabel, + getStepStatus, + hasCustomStepLayout, + positionStep, + resolveConnections, + type ProcessFlowConnection, + type ProcessFlowOrientation, + type ProcessFlowStep, + type ProcessFlowStepStatus, +} from "./ProcessFlow.utils"; + +function positionSteps(steps: ProcessFlowStep[], orientation: ProcessFlowOrientation = "horizontal") { + return steps.map((step, index) => positionStep(step, index, orientation)); +} + +describe("ProcessFlow utilities", () => { + it("exports the shared step status values", () => { + expect(PROCESS_FLOW_STEP_STATUSES).toEqual(["pending", "active", "completed", "error", "disabled"]); + }); + + it("resolves description visibility from the public prop", () => { + expect(getDescriptionVisibility()).toBe("visible"); + expect(getDescriptionVisibility(true)).toBe("visible"); + expect(getDescriptionVisibility(false)).toBe("hidden"); + }); + + it("defaults step status to pending and lets disabled override the visual status", () => { + expect(getStepStatus({ id: "pending", label: "Pending" })).toBe("pending"); + expect(getStepStatus({ id: "disabled", label: "Disabled", status: "completed", disabled: true })).toBe("disabled"); + }); + + it("builds accessible labels from the explicit label, string label, or step number", () => { + expect(getStepAccessibleLabel({ id: "a", label: "Upload" }, "completed", 1)).toBe("Upload, Completed"); + expect(getStepAccessibleLabel({ id: "b", label: "Validate", ariaLabel: "View validation" }, "active", 2)).toBe( + "View validation", + ); + expect(getStepAccessibleLabel({ id: "c", label: ["Icon"] }, "pending", 3)).toBe("Step 3, Pending"); + }); + + it("positions linear steps from orientation defaults and normalizes custom grid indexes", () => { + expect(positionStep({ id: "a", label: "A" }, 2, "horizontal")).toMatchObject({ + column: 2, + row: 0, + status: "pending", + stepIndex: 2, + }); + expect(positionStep({ id: "a", label: "A" }, 2, "vertical")).toMatchObject({ + column: 0, + row: 2, + }); + expect( + positionStep({ id: "a", label: "A", position: { column: 2.8, row: -1 }, status: "active" }, 0, "horizontal"), + ).toMatchObject({ + column: 2, + row: 0, + status: "active", + }); + }); + + it.each<[ProcessFlowStepStatus, ProcessFlowStepStatus, ProcessFlowStepStatus]>([ + ["error", "pending", "error"], + ["completed", "error", "error"], + ["completed", "disabled", "disabled"], + ["completed", "completed", "completed"], + ["completed", "active", "completed"], + ["active", "pending", "active"], + ["pending", "active", "active"], + ["pending", "pending", "pending"], + ])("derives connection status from %s to %s as %s", (fromStatus, toStatus, expected) => { + expect(deriveConnectionStatus(fromStatus, toStatus)).toBe(expected); + }); + + it("derives linear connections from positioned steps", () => { + const steps = positionSteps([ + { id: "upload", label: "Upload", status: "completed" }, + { id: "validate", label: "Validate", status: "active" }, + { id: "publish", label: "Publish", status: "pending" }, + ]); + + expect( + resolveConnections(steps).map((connection) => ({ + from: connection.from, + id: connection.id, + status: connection.status, + to: connection.to, + })), + ).toEqual([ + { from: "upload", id: "upload-validate-0", status: "completed", to: "validate" }, + { from: "validate", id: "validate-publish-1", status: "active", to: "publish" }, + ]); + }); + + it("keeps explicit connection status and skips connections with unknown steps", () => { + const steps = positionSteps([ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ]); + const connections: ProcessFlowConnection[] = [ + { id: "known", from: "a", to: "c", status: "disabled" }, + { from: "unknown", to: "b" }, + { from: "a", to: "unknown" }, + ]; + + expect( + resolveConnections(steps, connections).map((connection) => ({ + from: connection.from, + id: connection.id, + status: connection.status, + to: connection.to, + })), + ).toEqual([{ from: "a", id: "known", status: "disabled", to: "c" }]); + }); + + it("generates straight and curved SVG paths from resolved branching connections", () => { + const sameRowSteps = positionSteps([ + { id: "a", label: "A", position: { column: 0, row: 0 } }, + { id: "b", label: "B", position: { column: 1, row: 0 } }, + ]); + const curvedSteps = positionSteps([ + { id: "a", label: "A", position: { column: 0, row: 0 } }, + { id: "b", label: "B", position: { column: 1, row: 1 } }, + ]); + + expect(getConnectionPath(resolveConnections(sameRowSteps, [{ from: "a", to: "b" }])[0], 1, 2)).toBe( + "M 25 50 L 75 50", + ); + expect(getConnectionPath(resolveConnections(curvedSteps, [{ from: "a", to: "b" }])[0], 2, 2)).toBe( + "M 25 25 C 50 25, 50 75, 75 75", + ); + }); + + it("detects custom step layout positions", () => { + expect(hasCustomStepLayout([{ id: "a", label: "A" }])).toBe(false); + expect(hasCustomStepLayout([{ id: "a", label: "A", position: { column: 1 } }])).toBe(true); + }); +}); diff --git a/src/components/composed/ProcessFlow/ProcessFlow.tsx b/src/components/composed/ProcessFlow/ProcessFlow.tsx new file mode 100644 index 00000000..dec8fc22 --- /dev/null +++ b/src/components/composed/ProcessFlow/ProcessFlow.tsx @@ -0,0 +1,708 @@ +import { CheckIcon, DotIcon, LockIcon, TriangleAlertIcon } from "lucide-react"; + +import { + getConnectionPath, + getDescriptionVisibility, + getStepAccessibleLabel, + hasCustomStepLayout, + positionStep, + resolveConnections, + STATUS_LABELS, + type PositionedStep, + type ProcessFlowConnection, + type ProcessFlowDescriptionVisibility, + type ProcessFlowOrientation, + type ProcessFlowSize, + type ProcessFlowStep, + type ProcessFlowStepStatus, +} from "./ProcessFlow.utils"; + +import type { ComponentPropsWithoutRef, CSSProperties, MouseEvent } from "react"; + +import { cn } from "@/lib/utils"; + +export { PROCESS_FLOW_STEP_STATUSES } from "./ProcessFlow.utils"; +export type { + ProcessFlowConnection, + ProcessFlowOrientation, + ProcessFlowSize, + ProcessFlowStep, + ProcessFlowStepConfig, + ProcessFlowStepPosition, + ProcessFlowStepStatus, + ProcessStep, + ProcessStepStatus, +} from "./ProcessFlow.utils"; + +type ProcessFlowLayout = "linear" | "branching"; +type ProcessFlowStepContentLayout = "stacked" | "inline" | "anchored"; + +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. Set to false to hide step descriptions. */ + showDescriptions?: boolean; +} + +interface StepControlClassOptions { + layout: ProcessFlowLayout; + contentLayout: ProcessFlowStepContentLayout; + onStepSelect?: ProcessFlowProps["onStepSelect"]; + isDisabled: boolean; + isSelected: boolean; + size: ProcessFlowSize; + status: ProcessFlowStepStatus; +} + +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 getMarkerContent(status: ProcessFlowStepStatus, stepNumber: number) { + if (status === "completed") { + return