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: 1 addition & 4 deletions src/components/WorkflowDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,7 @@ export default function WorkflowDetail({
if (!features.workflowQueries) {
return (
<div>
<WorkflowListEmptyState
probeForExistingWorkflows={false}
showingAll={false}
/>
<WorkflowListEmptyState workflowQueriesEnabled={false} />
</div>
);
}
Expand Down
109 changes: 109 additions & 0 deletions src/components/WorkflowList.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { WorkflowListItem } from "@services/workflows";
import type { Meta, StoryObj } from "@storybook/react-vite";

import WorkflowList from "./WorkflowList";

const meta: Meta<typeof WorkflowList> = {
component: WorkflowList,
parameters: {
layout: "fullscreen",
router: {
routes: ["/", "/workflows", "/workflows/$workflowId"],
},
},
title: "Pages/WorkflowList",
};

export default meta;

type Story = StoryObj<typeof WorkflowList>;

const workflow = (
id: string,
name: string,
createdAt: string,
counts: Partial<
Pick<
WorkflowListItem,
| "countAvailable"
| "countCancelled"
| "countCompleted"
| "countDiscarded"
| "countFailedDeps"
| "countPending"
| "countRetryable"
| "countRunning"
| "countScheduled"
>
>,
): WorkflowListItem => ({
countAvailable: counts.countAvailable ?? 0,
countCancelled: counts.countCancelled ?? 0,
countCompleted: counts.countCompleted ?? 0,
countDiscarded: counts.countDiscarded ?? 0,
countFailedDeps: counts.countFailedDeps ?? 0,
countPending: counts.countPending ?? 0,
countRetryable: counts.countRetryable ?? 0,
countRunning: counts.countRunning ?? 0,
countScheduled: counts.countScheduled ?? 0,
createdAt: new Date(createdAt),
id,
name,
});

const workflows: WorkflowListItem[] = [
workflow("wf-onboarding-2026-05-01", "Customer onboarding", "2026-05-01", {
countCompleted: 5,
countPending: 2,
countRunning: 1,
}),
workflow("wf-nightly-ledger-close", "Nightly ledger close", "2026-04-29", {
countCompleted: 12,
}),
workflow("wf-import-retry-queue", "Import retry queue", "2026-04-28", {
countCompleted: 8,
countFailedDeps: 1,
}),
workflow(
"wf-backfill-with-a-very-long-identifier-for-layout",
"Long-running historical backfill with a verbose display name",
"2026-04-22",
{
countCompleted: 21,
countPending: 4,
countScheduled: 3,
},
),
];

export const Loading: Story = {
args: {
loading: true,
workflowItems: [],
workflowQueriesEnabled: true,
},
};

export const Populated: Story = {
args: {
loading: false,
workflowItems: workflows,
workflowQueriesEnabled: true,
},
};

export const NoWorkflows: Story = {
args: {
loading: false,
workflowItems: [],
workflowQueriesEnabled: true,
},
};

export const WorkflowsNotEnabled: Story = {
args: {
loading: false,
workflowItems: [],
workflowQueriesEnabled: false,
},
};
8 changes: 5 additions & 3 deletions src/components/WorkflowList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ type StateTab = { name: string; state: undefined | WorkflowState };

type WorkflowListProps = {
loading: boolean;
showingAll: boolean;
workflowItems: WorkflowListItem[];
workflowQueriesEnabled: boolean;
};
const tabs: StateTab[] = [
{ name: "All", state: undefined },
Expand Down Expand Up @@ -203,8 +203,8 @@ const WorkflowTable = ({

const WorkflowList = ({
loading,
showingAll,
workflowItems,
workflowQueriesEnabled,
}: WorkflowListProps) => {
return (
<div className="size-full">
Expand All @@ -225,7 +225,9 @@ const WorkflowList = ({
) : workflowItems.length > 0 ? (
<WorkflowTable workflowItems={workflowItems} />
) : (
<WorkflowListEmptyState showingAll={showingAll} />
<WorkflowListEmptyState
workflowQueriesEnabled={workflowQueriesEnabled}
/>
)}
</div>
</div>
Expand Down
40 changes: 40 additions & 0 deletions src/components/WorkflowListEmptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import WorkflowListEmptyState from "./WorkflowListEmptyState";

describe("WorkflowListEmptyState", () => {
it("shows migration guidance when workflow tables are unavailable", () => {
render(<WorkflowListEmptyState workflowQueriesEnabled={false} />);

expect(
screen.getByRole("heading", { name: "Build faster with Workflows" }),
).toBeInTheDocument();
expect(
screen.getByText(/run all River Pro migrations/i),
).toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "No workflows yet" }),
).not.toBeInTheDocument();
});

it("shows a neutral empty state when workflow tables are available", () => {
render(<WorkflowListEmptyState workflowQueriesEnabled />);

expect(
screen.getByRole("heading", { name: "No workflows yet" }),
).toBeInTheDocument();
expect(
screen.getByText(/coordinate fan-out, fan-in, retries/i),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Docs" })).toHaveAttribute(
"href",
"https://riverqueue.com/docs/pro/workflows",
);
expect(
screen.queryByRole("heading", {
name: "Build faster with Workflows",
}),
).not.toBeInTheDocument();
});
});
90 changes: 44 additions & 46 deletions src/components/WorkflowListEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,49 @@ import { Badge } from "@components/Badge";
import Logo from "@components/Logo";
import { ArrowRightIcon } from "@heroicons/react/20/solid";
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
import { listWorkflows, listWorkflowsKey } from "@services/workflows";
import { queryOptions, useQuery } from "@tanstack/react-query";

export default function WorkflowListEmptyState({
probeForExistingWorkflows = true,
showingAll,
workflowQueriesEnabled,
}: {
probeForExistingWorkflows?: boolean;
showingAll: boolean;
workflowQueriesEnabled: boolean;
}) {
const opts = queryOptions({
enabled: probeForExistingWorkflows && !showingAll,
queryFn: listWorkflows,
queryKey: listWorkflowsKey({ limit: 1, state: undefined }),
refetchInterval: 60000,
});

const anyWorkflowsQuery = useQuery(opts);
const hasExistingWorkflows =
anyWorkflowsQuery.isLoading ||
(probeForExistingWorkflows &&
!showingAll &&
(anyWorkflowsQuery.data || []).length > 0);

return (
<>
{hasExistingWorkflows && (
<div className="mx-4 rounded-lg border-2 border-dashed border-gray-300 px-4 py-12 text-center">
<RectangleGroupIcon
aria-hidden="true"
className="mx-auto size-12 text-slate-500 dark:text-slate-400"
/>
<h3 className="mt-4 text-sm font-semibold text-slate-900 dark:text-white">
No workflows
</h3>
<p className="mt-8 text-sm text-slate-600 dark:text-slate-400">
Check out{" "}
<a href="https://riverqueue.com/docs/pro/workflows">
the documentation
</a>{" "}
to learn more about workflows.
</p>
{workflowQueriesEnabled && (
<div className="flex justify-center">
<div className="mx-4 mt-12 flex max-w-xl flex-col gap-6 overflow-hidden rounded-lg border border-slate-400/30 bg-white py-6 shadow-lg md:mt-20 dark:bg-slate-800">
<div className="flex flex-col px-4 sm:px-6">
<div className="flex grow">
<RectangleGroupIcon
aria-hidden="true"
className="size-7 text-brand-primary dark:text-white"
/>
</div>
<h3 className="mt-4 text-lg leading-6 font-medium text-slate-900 dark:text-white">
No workflows yet
</h3>
</div>
<div className="flex flex-col gap-4 px-4 sm:px-6">
<p className="text-sm text-slate-800 dark:text-slate-100">
Workflows model a process as dependent tasks, making it easier
to coordinate fan-out, fan-in, retries, and progress across
related jobs.
</p>
</div>
<div className="flex gap-4 px-4 sm:px-6">
<a
className="flex items-center rounded-lg bg-transparent py-2 pr-4 text-sm text-slate-800 hover:text-slate-600 dark:text-slate-200 dark:hover:text-slate-400"
href="https://riverqueue.com/docs/pro/workflows"
>
Docs
<ArrowRightIcon className="ml-2 size-4" />
</a>
</div>
</div>
</div>
)}

{!hasExistingWorkflows && (
{!workflowQueriesEnabled && (
<div className="flex justify-center">
<div className="mx-4 mt-12 flex max-w-xl flex-col gap-6 overflow-hidden rounded-lg border border-slate-400/30 bg-white py-6 shadow-lg md:mt-20 dark:bg-slate-800">
<div className="flex flex-col px-4 sm:px-6">
Expand All @@ -62,19 +59,20 @@ export default function WorkflowListEmptyState({
<div className="flex flex-col gap-4 px-4 sm:px-6">
<p className="text-sm text-slate-800 dark:text-slate-100">
Model your jobs as a series of dependent tasks with Workflows.
Tasks don&apos;t execute until all their dependencies have
completed, and support fan-out and fan-in execution.
Tasks don't execute until all their dependencies have completed,
and support fan-out and fan-in execution.
</p>
<p className="text-sm text-slate-800 dark:text-slate-100">
Workflows are included with River Pro. If you're already using
Pro,{" "}
Workflows are part of River Pro. If you're not using Pro yet,{" "}
<a
className="text-brand-primary"
href="https://riverqueue.com/docs/river-ui"
href="https://riverqueue.com/pro"
>
upgrade your deployment
</a>{" "}
to access Pro features in the UI.
learn about Workflows
</a>
. If you're already using Pro, ensure you've run all River Pro
migrations against the configured schema to access workflow
features in the UI.{" "}
</p>
</div>
<div className="flex gap-4 px-4 sm:px-6">
Expand All @@ -86,7 +84,7 @@ export default function WorkflowListEmptyState({
</a>
<a
className="flex items-center rounded-lg bg-transparent px-4 py-2 text-sm text-slate-800 hover:text-slate-600 dark:text-slate-200 dark:hover:text-slate-400"
href="https://riverqueue.com/pro"
href="https://riverqueue.com/docs/pro/workflows"
>
Docs
<ArrowRightIcon className="ml-2 size-4" />
Expand Down
5 changes: 2 additions & 3 deletions src/routes/workflows/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ export const Route = createFileRoute("/workflows/")({
function WorkflowsIndexComponent() {
const refreshSettings = useRefreshSetting();
const refetchInterval = refreshSettings.intervalMs;
const { workflowsQueryOptions } = Route.useRouteContext();
const loaderDeps = Route.useLoaderDeps();
const { features, workflowsQueryOptions } = Route.useRouteContext();
const workflowsQuery = useQuery({
...workflowsQueryOptions,
refetchInterval,
Expand All @@ -58,8 +57,8 @@ function WorkflowsIndexComponent() {
return (
<WorkflowList
loading={workflowsQuery.isLoading}
showingAll={!loaderDeps.state}
workflowItems={workflowsQuery.data || []}
workflowQueriesEnabled={features.workflowQueries}
/>
);
}
Expand Down
Loading