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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Workflow detail: prevent the viewport from getting stuck zoomed into empty space after navigating between the workflow list and detail pages by remounting the diagram per workflow and enabling fit-to-view on mount. [PR #408](https://github.com/riverqueue/riverui/pull/408)
- Workflow detail: make MiniMap render nodes correctly and respect dark mode by setting explicit node bounds and theme-aware colors. [PR #408](https://github.com/riverqueue/riverui/pull/408)

## [v0.12.2] - 2025-08-16

No changes from v0.12.0 except fixes to the release process. v0.12.0 is not usable, this version should be used instead.
Expand Down
50 changes: 43 additions & 7 deletions src/components/WorkflowDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import type {
} from "@xyflow/react";

import WorkflowNode, { WorkflowNodeData } from "@components/WorkflowNode";

import "./reactflow-base.css";
import dagre from "@dagrejs/dagre";
import "@xyflow/react/dist/style.css";
import { JobWithKnownMetadata } from "@services/jobs";
import { JobState } from "@services/types";
import { MiniMap, ReactFlow } from "@xyflow/react";
Expand Down Expand Up @@ -87,7 +88,8 @@ const edgeColorsDark = {

const depStatusFromJob = (job: JobWithKnownMetadata) => {
switch (job.state) {
case (JobState.Cancelled, JobState.Discarded):
case JobState.Cancelled:
case JobState.Discarded:
return "failed";
case JobState.Completed:
return "unblocked";
Expand All @@ -114,6 +116,27 @@ export default function WorkflowDiagram({

const minimapMaskColor =
resolvedTheme === "dark" ? "rgb(5, 5, 5, 0.5)" : "rgb(250, 250, 250, 0.5)";
const getMiniMapNodeClassName = (
node: Node<WorkflowNodeData, NodeTypeKey>,
): string => {
const state = node.data?.job?.state;
switch (state) {
case JobState.Available:
case JobState.Pending:
case JobState.Retryable:
case JobState.Scheduled:
return "fill-amber-300/60 stroke-amber-500/60 dark:fill-amber-700/50 dark:stroke-amber-400/50 stroke-1";
case JobState.Cancelled:
case JobState.Discarded:
return "fill-red-300/60 stroke-red-500/60 dark:fill-red-700/50 dark:stroke-red-400/50 stroke-1";
case JobState.Completed:
return "fill-green-300/60 stroke-green-500/60 dark:fill-green-500/70 dark:stroke-green-300/70 stroke-1";
case JobState.Running:
return "fill-blue-300/60 stroke-blue-500/60 dark:fill-blue-700/50 dark:stroke-blue-400/50 stroke-1";
default:
return "fill-slate-300/60 stroke-slate-600/60 dark:fill-slate-700/50 dark:stroke-slate-400/50 stroke-1";
}
};

// TODO: not ideal to iterate through this list so many times. Should probably
// do that once and save all results at the same time.
Expand All @@ -139,10 +162,12 @@ export default function WorkflowDiagram({
hasUpstreamDeps: job.metadata.deps?.length > 0,
job,
},
height: nodeHeight,
id: job.id.toString(),
position: { x: 0, y: 0 },
selected: selectedJobId === job.id,
type: "workflowNode",
width: nodeWidth,
})),
[tasks, selectedJobId, tasksWithDownstreamDeps],
);
Expand Down Expand Up @@ -184,6 +209,10 @@ export default function WorkflowDiagram({
"LR",
);

// Use workflow id to scope/reset the ReactFlow instance between navigations
const workflowIdForInstance =
tasks[0]?.metadata.workflow_id ?? "unknown-workflow";

const isNodeSelectionChange = (
change: NodeChange,
): change is NodeSelectionChange => {
Expand Down Expand Up @@ -211,21 +240,28 @@ export default function WorkflowDiagram({
<ReactFlow
defaultViewport={{ x: 32, y: 32, zoom: 1 }}
edges={layoutedEdges}
fitView
fitViewOptions={{ padding: 0.2 }}
id={`workflow-diagram-${workflowIdForInstance}`}
key={`workflow-diagram-${workflowIdForInstance}`}
minZoom={0.8}
nodes={layoutedNodes}
// fitView
nodesFocusable={true}
nodeTypes={nodeTypes}
onEdgesChange={(_newEdges) => {}}
onNodesChange={onNodesChange}
proOptions={{ hideAttribution: true }}
>
<MiniMap
className="hidden bg-slate-400 md:block dark:bg-slate-500"
className="hidden md:block"
maskColor={minimapMaskColor}
// TODO: dynamic class name based on state
nodeClassName="fill-slate-500 dark:fill-slate-800"
nodeClassName={getMiniMapNodeClassName}
pannable
style={{ height: 100, width: 150 }}
style={{
backgroundColor: resolvedTheme === "dark" ? "#64748b" : "#94a3b8",
height: 100,
width: 150,
}}
zoomable
/>
</ReactFlow>
Expand Down
10 changes: 8 additions & 2 deletions src/components/WorkflowNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import type { Node, NodeProps } from "@xyflow/react";
import { TaskStateIcon } from "@components/TaskStateIcon";
import { JobWithKnownMetadata } from "@services/jobs";
import { JobState } from "@services/types";
import { Handle, Position } from "@xyflow/react";
import { Handle, Position, useUpdateNodeInternals } from "@xyflow/react";
import clsx from "clsx";
import { differenceInSeconds } from "date-fns";
import { memo, useMemo } from "react";
import { memo, useEffect, useMemo } from "react";
import { useTime } from "react-time-sync";

export type WorkflowNodeData = {
Expand All @@ -20,6 +20,12 @@ type WorkflowNode = Node<WorkflowNodeData, "workflow">;
const WorkflowNode = memo(
({ data, isConnectable, selected }: NodeProps<WorkflowNode>) => {
const { hasDownstreamDeps, hasUpstreamDeps, job } = data;
const updateNodeInternals = useUpdateNodeInternals();

// Ask xyflow to re-measure this custom node after mount/updates so MiniMap gets correct bounds
useEffect(() => {
updateNodeInternals(String(job.id));
}, [job.id, updateNodeInternals]);

return (
<div
Expand Down
1 change: 1 addition & 0 deletions src/components/reactflow-base.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "@xyflow/react/dist/style.css" layer(base);
4 changes: 2 additions & 2 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
Expand Down Expand Up @@ -27,7 +27,7 @@ export default defineConfig({
plugins: [
tailwindcss(),
react(),
TanStackRouterVite({
tanstackRouter({
routeFileIgnorePattern: ".(const|schema|test).(ts|tsx)",
}),
tsconfigPaths(),
Expand Down