Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config
| [`graph-store-sqlite`](packages/adapters/graph/store-sqlite/README.md) | The store port on SQLite through Drizzle: one table definition, committed drizzle-kit migrations applied the same way in every runtime, recursive SQL for the structural queries, against Drizzle's async SQLite database so any driver plugs in. |
| [`graph-sqlite-node`](packages/adapters/graph/sqlite-node/README.md) | The SQLite store on Node's built-in `node:sqlite` through `drizzle-orm/node-sqlite`, for the command line and servers. |
| [`graph-sqlite-wasm`](packages/adapters/graph/sqlite-wasm/README.md) | The SQLite store in the browser: SQLite wasm in a dedicated worker on OPFS `opfs-sahpool`, shared between tabs under a Web Lock with no special headers, served to the page over oRPC. |
| [`graph-react`](packages/ui/graph/react/README.md) | Headless React hooks and unstyled components: follow a stored model, derive statuses, the ready set, blocked chains, impact and explanations, project views, filter, select and navigate by keyboard. |
| [`graph-ui`](packages/ui/graph/ui/README.md) | Styled components over `graph-react` and an SVG sequence diagram, in Vanilla Extract with a typed theme contract and light and dark themes, shipped as compiled CSS. |
| [`graph-react-flow`](packages/ui/graph/react-flow/README.md) | React Flow views of the model: flowchart, swimlane with lanes as group nodes, and state machine, laid out by `graph-layout`, showing each status and the critical path; sequence projections drawn by `graph-ui`. |
| [`process-graph`](packages/targets/process-graph/README.md) | The primary entry point: re-exports the schema, projection and domain APIs flat and the DAG API as the `dag` namespace. |

## Conventions
Expand Down
27 changes: 23 additions & 4 deletions eslint.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ const dataFileLintConfig = defineConfig(
files: ["**/package.json"],
plugins: { depend, exadev },
rules: {
"depend/ban-dependencies": ["error", { allowed: ["lint-staged"] }],
// eslint-plugin-react is the plugin @exadev/eslint-config's React block is built on, so the React packages' lint needs it whatever replacement the ban list suggests.
"depend/ban-dependencies": [
"error",
{ allowed: ["lint-staged", "eslint-plugin-react"] },
],
// A script turbo caches must not pass a fixing flag: turbo hashes a task's inputs before it runs, so a cached check that rewrites them passes in CI having fixed files only inside the runner, and a later cache hit replays a log describing a fix that was never applied. Checking stays in the cached `_lint`; fixing is the uncached `_lint:fix`.
"exadev/no-fix-in-cached-task-script": "error",
},
Expand Down Expand Up @@ -159,6 +163,11 @@ export interface PackageLintOptions {

/** `@exadev/eslint-config`'s own `exadev/barrel-policy` rule defaults to `"banned"` in its recommended type-checked preset. Every published package here exposes exactly one barrel at `src/index.ts`, so this defaults to `"single"` instead. It explicitly overrides the base preset's own default, rather than merely relying on it. */
readonly barrelPolicy?: "banned" | "single" | "siblings" | "off";

/**
* Set for a package that renders React components: `.tsx` files get `@exadev/eslint-config`'s React block (eslint-plugin-react with the automatic JSX runtime, the hooks rules and the jsx-a11y rules). `version` is the React version the rules assume; pass React's own `version` export, so it follows the package's dependency. eslint-plugin-react's `"detect"` cannot be used, because it reads the file name through a context method ESLint 10 removed.
*/
readonly react?: { readonly version: string };
}

export function packageLintConfig(options: PackageLintOptions): Config[] {
Expand All @@ -173,11 +182,14 @@ export function packageLintConfig(options: PackageLintOptions): Config[] {
isomorphicExemptions = [],
additionalRestrictedSyntax = [],
barrelPolicy = "single",
react,
} = options;

const typeScriptFiles = ["**/*.ts", "**/*.tsx"];
const runtimeSrcFiles = ["src/**/*.ts", "src/**/*.tsx"];
const runtimeSrcExemptions = [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/test-support/**",
...isomorphicExemptions,
];
Expand Down Expand Up @@ -248,7 +260,7 @@ export function packageLintConfig(options: PackageLintOptions): Config[] {
},
isomorphicImportPatterns.length > 0
? {
files: ["src/**/*.ts"],
files: runtimeSrcFiles,
ignores: [...runtimeSrcExemptions],
rules: {
"no-restricted-imports": [
Expand All @@ -260,7 +272,7 @@ export function packageLintConfig(options: PackageLintOptions): Config[] {
: [],
isomorphic
? {
files: ["src/**/*.ts"],
files: runtimeSrcFiles,
ignores: [...runtimeSrcExemptions],
rules: {
"no-restricted-globals": [
Expand All @@ -272,13 +284,20 @@ export function packageLintConfig(options: PackageLintOptions): Config[] {
: [],
additionalRestrictedSyntax.length > 0
? {
files: ["src/**/*.ts"],
files: runtimeSrcFiles,
ignores: [...runtimeSrcExemptions],
rules: {
"no-restricted-syntax": ["error", ...additionalRestrictedSyntax],
},
}
: [],
react === undefined
? []
: [
// Nested rather than spread: at runtime this is an array of config blocks, which defineConfig flattens.
exadev.configs.react,
{ settings: { react: { version: react.version } } },
],
dataFileLintConfig,
// Last, deliberately: bundles eslint-config-prettier, turning off every stylistic rule that would otherwise fight the formatter. Placed earlier, a later config could re-enable one and the two would disagree forever, each "fixing" the other's output.
prettierRecommended,
Expand Down
4 changes: 4 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const config: KnipConfig = {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
},
"packages/ui/*/*": {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
},
"packages/targets/*": {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@
"eslint": "10.12.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-depend": "1.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-prettier": "5.5.6",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-yml": "3.8.1",
"globals": "17.13.0",
"husky": "9.1.7",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/graph/react-flow/AGENTS.md
1 change: 1 addition & 0 deletions packages/ui/graph/react-flow/CLAUDE.md
63 changes: 63 additions & 0 deletions packages/ui/graph/react-flow/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# @exadev/process-graph-react-flow

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions)

[React Flow](https://reactflow.dev) views of the process model: a flowchart, a swimlane with each lane a group node, and a state machine, laid out by `@exadev/process-graph-layout`, drawn with `@exadev/process-graph-ui`'s styled cards, and reasoned over with `@exadev/process-graph-react`'s hooks. Each element shows its status (ready, blocked, done, skipped or unknown) and what blocks it, and the critical path is highlighted on its cards and its steps. A sequence projection is drawn by graph-ui's SVG `SequenceDiagram`, since React Flow has no lifeline layout. A private workspace package, published as part of `process-graph`. `src/` imports nothing Node-only.

```tsx
import "@exadev/process-graph-ui/style.css";
import "@exadev/process-graph-react-flow/style.css";
import { GraphTheme } from "@exadev/process-graph-ui";
import { ProcessView } from "@exadev/process-graph-react-flow";
import { noFacts } from "@exadev/process-graph-react";

const run = { model, overlay, facts: noFacts };
const lanes = { id: "lanes", notation: "swimlane" } as const;

<GraphTheme theme="light">
<div style={{ height: "40rem" }}>
<ProcessView
run={run}
projection={lanes}
label="Release plan"
onSelect={setSelected}
/>
</div>
</GraphTheme>;
```

## Components

| Export | What it is |
| ------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `ProcessView` | Any projection: a sequence projection as graph-ui's `SequenceDiagram`, every other as a `ProcessFlow`. |
| `ProcessFlow` | A flowchart, swimlane or state machine on a React Flow canvas. |
| `toFlow` | The pure conversion of a projected, laid-out view and a run into React Flow nodes and edges. |
| `useLayout` | Lays out a view with `layout`, aborting a layout that is no longer wanted. |
| `nodeTypes` | The node components: `element` (graph-ui's element card with its handles) and `lane` (graph-ui's lane filling its group). |
| `edgeTypes` | The edge component: `transition`, a stepped line with an arrowhead and its label. |

`ProcessFlow` takes the run (`{ model, overlay, facts }`), a flowchart, swimlane or state machine projection, and an accessible `label`. Optionally: `layout`, the options passed to `graph-layout` (direction, spacing and the engine); `filter`, an `ElementFilter` that hides the elements it leaves out and every edge with a hidden end; `colorMode`, React Flow's own; and `onSelect`, called with each element a click or key selects. Keep the run's parts, the projection and the layout options stable between renders: a change lays the view out again.

## Layout

Positions and sizes come from `layout(view, options)`. The engine is injectable, so a test, or an application that lays out off the main thread, passes its own `LayoutEngine` in `layout.engine`; it defaults to elkjs. Each element node gets the size the layout gave it and its handles up front, on the sides the layout direction implies, so React Flow routes edges without measuring the nodes first. In a swimlane each lane is a group node at its laid-out bounds, holding its participant's elements at positions relative to it; elements with no actor, such as the start and end, sit outside every lane. Nothing is drawn until the layout resolves, and a layout that fails throws to the nearest error boundary.

## What is drawn

- An element node is graph-ui's `ElementCard`: its label, its status as a badge and its colour on the card's leading edge, a critical path marker, and "Blocked by" with the open work at the end of its blocked chain. The card carries `data-element-id`, `data-kind`, `data-status` and `data-critical`.
- A transition edge carries `data-transitions` with the model transitions it stands for. In a swimlane a hand-off is drawn in the hand-off colour and carries `data-handoff`; an edge between consecutive elements of the critical path is drawn thicker in the critical colour and carries `data-critical`. A state machine transition is labelled `event [guard] / effect`.
- Selection: a click selects an element and a click on the canvas clears it; once the canvas has focus, the arrow keys, Home, End and Escape move the selection through the shown elements in model order and focus the selected node, as `useGraphSelection` does.

## Styles

React Flow's stylesheet is imported once, by the build, and bundled with this package's own rules into `dist/style.css`: import it beside graph-ui's stylesheet, and put the view inside a graph-ui theme. Every colour is read from graph-ui's theme contract, so a theme assigned to the contract restyles the canvas too. React Flow needs its container to have a size.

## Commands

```sh
pnpm build # vite build (JavaScript and CSS) and tsc -b (declarations) -> dist/
pnpm test # unit tests in happy-dom, laid out by a stub engine
pnpm test:smoke # the built dist/ rendered as a real artefact
pnpm test:mutation # Stryker
```
15 changes: 15 additions & 0 deletions packages/ui/graph/react-flow/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig } from "eslint/config";
import { version } from "react";
import { packageLintConfig } from "../../../../eslint.shared.ts";

/**
* A browser React library: runtime `src/` imports nothing Node-only, browser globals are in scope, and `.tsx` files get the React, hooks and accessibility rules for the React this package installs.
*/
export default defineConfig(
packageLintConfig({
tsconfigRootDir: import.meta.dirname,
isomorphic: true,
browserGlobals: true,
react: { version },
}),
);
92 changes: 92 additions & 0 deletions packages/ui/graph/react-flow/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
{
"name": "@exadev/process-graph-react-flow",
"version": "0.0.0",
"private": true,
"description": "React Flow views of the process model: a flowchart, a swimlane with lanes as group nodes and a state machine, laid out by graph-layout and styled by graph-ui, showing each element's status and the critical path; a sequence projection is drawn by graph-ui's SVG sequence diagram.",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/ui/graph/react-flow"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/ui/graph/react-flow#readme",
"bugs": {
"url": "https://github.com/ExaDev/process-graph/issues"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./style.css": "./dist/style.css"
},
"types": "./dist/index.d.ts",
"files": [
"dist",
"!dist/*.tsbuildinfo"
],
"sideEffects": [
"*.css"
],
"license": "MIT",
"scripts": {
"build": "turbo run _build",
"_build": "vite build && tsc -b tsconfig.build.json",
"lint": "turbo run _lint",
"_lint": "eslint . --cache --max-warnings 0",
"_lint:fix": "eslint . --fix --cache --max-warnings 0",
"typecheck": "turbo run _typecheck _typecheck:attw",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
"_typecheck:attw": "attw --pack --profile esm-only --exclude-entrypoints ./style.css",
"test": "turbo run _test",
"_test": "vitest run --project unit",
"test:coverage": "turbo run _test:coverage",
"_test:coverage": "vitest run --project unit --coverage",
"test:smoke": "turbo run _test:smoke",
"_test:smoke": "vitest run --project smoke",
"test:mutation": "turbo run _test:mutation",
"_test:mutation": "stryker run stryker.config.ts"
},
"keywords": [
"process-model",
"react",
"react-flow",
"flowchart",
"swimlane",
"state-machine"
],
"dependencies": {
"@exadev/process-graph-domain": "1.1.1",
"@exadev/process-graph-layout": "1.0.3",
"@exadev/process-graph-projection": "1.1.0",
"@exadev/process-graph-react": "0.0.0",
"@exadev/process-graph-schema": "1.1.0",
"@exadev/process-graph-ui": "0.0.0",
"@xyflow/react": "12.12.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/typescript-checker": "10.0.0",
"@stryker-mutator/vitest-runner": "10.0.0",
"@testing-library/dom": "10.4.2",
"@testing-library/react": "16.3.3",
"@types/node": "26.6.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vanilla-extract/css": "1.21.2",
"@vanilla-extract/vite-plugin": "5.2.6",
"@vitest/coverage-v8": "5.0.3",
"eslint": "10.12.0",
"happy-dom": "20.14.5",
"react": "19.3.0",
"react-dom": "19.3.0",
"typescript": "6.0.3",
"vite": "8.3.2",
"vitest": "5.0.3"
},
"peerDependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
}
}
61 changes: 61 additions & 0 deletions packages/ui/graph/react-flow/src/edges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
import type { ReactElement } from "react";
import type { TransitionEdge } from "./flow.ts";
import { criticalEdge, handoffEdge, transitionEdge } from "./flow.css.ts";

/**
* A transition: a stepped line with an arrowhead and its label, coloured as a hand-off or a step of the critical path. Its group carries `data-transitions` with the model transitions it stands for, and `data-handoff` and `data-critical` as they apply.
* @param props - The edge React Flow renders, with the transition's data.
* @returns The edge's SVG.
*/
export const TransitionEdgeView = (
props: EdgeProps<TransitionEdge>,
): ReactElement => {
const {
id,
data,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
markerEnd,
} = props;
const [path, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const classes = [
transitionEdge,
data?.handoff === true ? handoffEdge : undefined,
data?.critical === true ? criticalEdge : undefined,
].filter((name) => name !== undefined);

return (
<g
className={classes.join(" ")}
data-transitions={data?.transitions.join(" ")}
data-handoff={data?.handoff === true ? "" : undefined}
data-critical={data?.critical === true ? "" : undefined}
>
<BaseEdge
id={id}
path={path}
{...(markerEnd === undefined ? {} : { markerEnd })}
label={data?.label}
labelX={labelX}
labelY={labelY}
/>
</g>
);
};

/** The edge components, by edge type. Defined once, outside any component, as React Flow requires. */
export const edgeTypes = {
transition: TransitionEdgeView,
};
Loading
Loading