Route workflow editor panes

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-14 11:03:03 -07:00
parent 8b7948c507
commit b8d6277ac0
14 changed files with 204 additions and 64 deletions
@@ -155,6 +155,7 @@ export function useAppNavigation() {
params: {
workflowId,
},
search: { pane: "trigger" },
},
behavior,
),
@@ -181,7 +182,7 @@ export function useAppNavigation() {
params: {
workflowId,
},
search: { view: "duplicate" },
search: { pane: "trigger", view: "duplicate" },
},
behavior,
),
@@ -294,13 +295,8 @@ export function useAppNavigation() {
}, [canGoBack, goHome, router.history]);
const closeWorkflowEditor = React.useCallback(() => {
if (canGoBack) {
router.history.back();
return;
}
void goWorkflows({ replace: true });
}, [canGoBack, goWorkflows, router.history]);
void goWorkflows();
}, [goWorkflows]);
const closeForumPost = React.useCallback(
(channelId: string) => {
@@ -4,13 +4,16 @@ import {
type WorkflowEditorRoute,
WorkflowsScreen,
} from "@/features/workflows/ui/WorkflowsScreen";
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
type WorkflowsRouteScreenProps = {
editor?: WorkflowEditorRoute | null;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
};
export function WorkflowsRouteScreen({
editor = null,
onEditorPaneChange,
}: WorkflowsRouteScreenProps) {
const {
closeWorkflowEditor,
@@ -36,6 +39,7 @@ export function WorkflowsRouteScreen({
onEditWorkflow={(workflowId) => {
void goWorkflow(workflowId);
}}
onEditorPaneChange={onEditorPaneChange}
/>
);
}
@@ -1,12 +1,17 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import {
parseWorkflowEditorPane,
serializeWorkflowEditorPane,
} from "@/features/workflows/ui/workflowEditorPane";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows/$workflowId")({
component: WorkflowEditorRouteComponent,
validateSearch: (search: Record<string, unknown>) => ({
pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)),
view: search.view === "duplicate" ? search.view : undefined,
}),
});
@@ -18,16 +23,27 @@ const WorkflowsRouteScreen = React.lazy(async () => {
function WorkflowEditorRouteComponent() {
usePreviewFeatureWarning("workflows");
const navigate = Route.useNavigate();
const { workflowId } = Route.useParams();
const { view } = Route.useSearch();
const { pane, view } = Route.useSearch();
return (
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsRouteScreen
editor={{
mode: view === "duplicate" ? "duplicate" : "edit",
pane: parseWorkflowEditorPane(pane),
workflowId,
}}
onEditorPaneChange={(nextPane) => {
void navigate({
resetScroll: false,
search: {
pane: serializeWorkflowEditorPane(nextPane),
view,
},
});
}}
/>
</React.Suspense>
);
+21 -2
View File
@@ -1,12 +1,17 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import {
parseWorkflowEditorPane,
serializeWorkflowEditorPane,
} from "@/features/workflows/ui/workflowEditorPane";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows")({
component: WorkflowsRouteComponent,
validateSearch: (search: Record<string, unknown>) => ({
pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)),
view: search.view === "create" ? search.view : undefined,
}),
});
@@ -18,12 +23,26 @@ const WorkflowsRouteScreen = React.lazy(async () => {
function WorkflowsRouteComponent() {
usePreviewFeatureWarning("workflows");
const { view } = Route.useSearch();
const navigate = Route.useNavigate();
const { pane, view } = Route.useSearch();
return (
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsRouteScreen
editor={view === "create" ? { mode: "create" } : null}
editor={
view === "create"
? { mode: "create", pane: parseWorkflowEditorPane(pane) }
: null
}
onEditorPaneChange={(nextPane) => {
void navigate({
resetScroll: false,
search: {
pane: serializeWorkflowEditorPane(nextPane),
view,
},
});
}}
/>
</React.Suspense>
);
@@ -34,6 +34,7 @@ type ChannelComboboxProps = {
allowEmpty?: boolean;
ariaLabel?: string;
channels: Channel[];
defaultOpen?: boolean;
disabled?: boolean;
emptyLabel?: string;
id?: string;
@@ -48,6 +49,7 @@ export function ChannelCombobox({
allowEmpty = false,
ariaLabel = "Channel",
channels,
defaultOpen = false,
disabled,
emptyLabel = "Choose a channel",
id,
@@ -57,7 +59,7 @@ export function ChannelCombobox({
variant = "header",
value,
}: ChannelComboboxProps) {
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(defaultOpen);
const [query, setQuery] = React.useState("");
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
@@ -1,23 +0,0 @@
import type { Channel } from "@/shared/api/types";
import { WorkflowDialog } from "./WorkflowDialog";
type CreateWorkflowDialogProps = {
channels: Channel[];
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function CreateWorkflowDialog({
channels,
onOpenChange,
open,
}: CreateWorkflowDialogProps) {
return (
<WorkflowDialog
channels={channels}
mode="create"
onOpenChange={onOpenChange}
open={open}
/>
);
}
@@ -34,6 +34,7 @@ import {
type WorkflowEditorMode,
} from "./WorkflowFormBuilder";
import { WorkflowWebhookSecretDialog } from "./WorkflowWebhookSecretDialog";
import type { WorkflowEditorPane } from "./workflowEditorPane";
import { yamlToFormState } from "./workflowFormTypes";
type DialogMode = "create" | "edit" | "duplicate";
@@ -41,8 +42,10 @@ type DialogMode = "create" | "edit" | "duplicate";
type WorkflowDialogProps = {
channels: Channel[];
mode: DialogMode;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
onOpenChange: (open: boolean) => void;
open: boolean;
pane: WorkflowEditorPane;
workflow?: Workflow | null;
};
@@ -84,8 +87,10 @@ const PENDING_LABELS: Record<DialogMode, string> = {
export function WorkflowDialog({
channels,
mode,
onEditorPaneChange,
onOpenChange,
open,
pane,
workflow,
}: WorkflowDialogProps) {
const channelId =
@@ -161,7 +166,14 @@ export function WorkflowDialog({
selectedChannelId !== initialValuesRef.current.channelId;
const navigationBlocker = useBlocker({
enableBeforeUnload: isDirty,
shouldBlockFn: () => isDirty && !allowNavigationRef.current,
shouldBlockFn: ({ current, next }) => {
const currentSearch = current.search as { view?: unknown };
const nextSearch = next.search as { view?: unknown };
const staysInEditor =
current.pathname === next.pathname &&
currentSearch.view === nextSearch.view;
return isDirty && !allowNavigationRef.current && !staysInEditor;
},
withResolver: true,
});
@@ -283,17 +295,20 @@ export function WorkflowDialog({
mutation.reset();
setYamlDefinition(yaml);
}}
onSelectedNodeChange={onEditorPaneChange}
parseError={editorParseError}
scopeField={
showChannelSelector ? (
<div className="space-y-1">
<ChannelCombobox
channels={channels}
defaultOpen={mode === "create"}
disabled={mutation.isPending}
id="wf-channel-select"
onChange={(value) => {
mutation.reset();
setSelectedChannelId(value);
if (value) onEditorPaneChange({ type: "trigger" });
}}
required
value={selectedChannelId}
@@ -312,6 +327,7 @@ export function WorkflowDialog({
</div>
) : null
}
selectedNode={pane}
workflowChannelId={selectedChannelId || null}
yaml={yamlDefinition}
/>
@@ -60,6 +60,7 @@ import type {
import { defaultScheduleTrigger } from "./workflowSchedule";
import { workflowStepDescription } from "./workflowStepDescription";
import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation";
import type { WorkflowEditorPane } from "./workflowEditorPane";
const TRIGGER_ICONS: Record<TriggerType, LucideIcon> = {
diff_posted: GitPullRequest,
@@ -149,20 +150,17 @@ type WorkflowFormBuilderProps = {
footerLeadingContainer?: HTMLElement | null;
mode: WorkflowEditorMode;
onChange: (yaml: string) => void;
onSelectedNodeChange: (node: WorkflowEditorPane) => void;
parseError: string | null;
scopeField?: React.ReactNode;
selectedNode: WorkflowEditorPane;
yaml: string;
workflowChannelId?: string | null;
};
export type WorkflowEditorMode = "form" | "yaml";
type SelectedNode =
| { type: "trigger" }
| { type: "step"; index: number }
| null;
function nodePosition(node: Exclude<SelectedNode, null>): number {
function nodePosition(node: Exclude<WorkflowEditorPane, null>): number {
return node.type === "trigger" ? 0 : node.index + 1;
}
@@ -370,8 +368,10 @@ export function WorkflowFormBuilder({
footerLeadingContainer,
mode,
onChange,
onSelectedNodeChange,
parseError,
scopeField,
selectedNode: selectedRouteNode,
yaml,
workflowChannelId,
}: WorkflowFormBuilderProps) {
@@ -382,9 +382,13 @@ export function WorkflowFormBuilder({
? initialParseRef.current.state
: DEFAULT_FORM_STATE,
);
const [selectedNode, setSelectedNode] = React.useState<SelectedNode>({
type: "trigger",
});
const selectedNode =
mode === "form" &&
(selectedRouteNode?.type === "trigger" ||
(selectedRouteNode?.type === "step" &&
selectedRouteNode.index < formState.steps.length))
? selectedRouteNode
: null;
const [selectionDirection, setSelectionDirection] = React.useState<1 | -1>(1);
const shouldReduceMotion = useReducedMotion();
const previousModeRef = React.useRef(mode);
@@ -412,18 +416,14 @@ export function WorkflowFormBuilder({
if (previousModeRef.current === mode) return;
previousModeRef.current = mode;
if (mode === "yaml") {
setSelectedNode(null);
return;
}
if (mode === "yaml") return;
const result = yamlToFormState(yaml);
if (result.ok) setFormState(result.state);
setSelectedNode({ type: "trigger" });
}, [mode, yaml]);
const selectNode = React.useCallback(
(nextNode: Exclude<SelectedNode, null>) => {
(nextNode: Exclude<WorkflowEditorPane, null>) => {
if (selectedNode) {
const currentPosition = nodePosition(selectedNode);
const nextPosition = nodePosition(nextNode);
@@ -431,9 +431,9 @@ export function WorkflowFormBuilder({
setSelectionDirection(nextPosition < currentPosition ? -1 : 1);
}
}
setSelectedNode(nextNode);
onSelectedNodeChange(nextNode);
},
[selectedNode],
[onSelectedNodeChange, selectedNode],
);
const insertStep = React.useCallback(
@@ -470,17 +470,20 @@ export function WorkflowFormBuilder({
if (selectedNode.index === index) {
setSelectionDirection(-1);
setSelectedNode(
onSelectedNodeChange(
index === 0
? { type: "trigger" }
: { type: "step", index: index - 1 },
);
} else if (selectedNode.index > index) {
setSelectionDirection(-1);
setSelectedNode({ type: "step", index: selectedNode.index - 1 });
onSelectedNodeChange({
type: "step",
index: selectedNode.index - 1,
});
}
},
[formState, selectedNode, updateFormState],
[formState, onSelectedNodeChange, selectedNode, updateFormState],
);
const updateStep = React.useCallback(
@@ -744,7 +747,7 @@ export function WorkflowFormBuilder({
<Button
aria-label="Close inspector"
className="h-8 w-8"
onClick={() => setSelectedNode(null)}
onClick={() => onSelectedNodeChange(null)}
size="icon"
type="button"
variant="ghost"
@@ -2,6 +2,7 @@ import * as React from "react";
import type { Channel } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
import type { WorkflowEditorPane } from "./workflowEditorPane";
const WorkflowsView = React.lazy(async () => {
const module = await import("@/features/workflows/ui/WorkflowsView");
@@ -9,8 +10,12 @@ const WorkflowsView = React.lazy(async () => {
});
export type WorkflowEditorRoute =
| { mode: "create" }
| { mode: "duplicate" | "edit"; workflowId: string };
| { mode: "create"; pane: WorkflowEditorPane }
| {
mode: "duplicate" | "edit";
pane: WorkflowEditorPane;
workflowId: string;
};
type WorkflowsScreenProps = {
channels: Channel[];
@@ -19,6 +24,7 @@ type WorkflowsScreenProps = {
onCreateWorkflow: () => void;
onDuplicateWorkflow: (workflowId: string) => void;
onEditWorkflow: (workflowId: string) => void;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
};
export function WorkflowsScreen({
@@ -28,6 +34,7 @@ export function WorkflowsScreen({
onCreateWorkflow,
onDuplicateWorkflow,
onEditWorkflow,
onEditorPaneChange,
}: WorkflowsScreenProps) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
@@ -39,6 +46,7 @@ export function WorkflowsScreen({
onCreateWorkflow={onCreateWorkflow}
onDuplicateWorkflow={onDuplicateWorkflow}
onEditWorkflow={onEditWorkflow}
onEditorPaneChange={onEditorPaneChange}
/>
</React.Suspense>
</div>
@@ -12,6 +12,7 @@ import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
import type { WorkflowEditorRoute } from "@/features/workflows/ui/WorkflowsScreen";
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
import {
getWorkflowEnabled,
withWorkflowEnabled,
@@ -34,6 +35,7 @@ type WorkflowsViewProps = {
onCreateWorkflow: () => void;
onDuplicateWorkflow: (workflowId: string) => void;
onEditWorkflow: (workflowId: string) => void;
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
};
type WorkflowWithChannel = {
@@ -88,6 +90,7 @@ export function WorkflowsView({
onCreateWorkflow,
onDuplicateWorkflow,
onEditWorkflow,
onEditorPaneChange,
}: WorkflowsViewProps) {
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
const queryClient = useQueryClient();
@@ -282,10 +285,12 @@ export function WorkflowsView({
: `${editor.mode}:${editor.workflowId}`
}
mode={editor.mode}
onEditorPaneChange={onEditorPaneChange}
onOpenChange={(open) => {
if (!open) onCloseEditor();
}}
open
pane={editor.pane}
workflow={editorWorkflow}
/>
) : null}
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseWorkflowEditorPane,
serializeWorkflowEditorPane,
} from "./workflowEditorPane.ts";
test("parses trigger and step panes", () => {
assert.deepEqual(parseWorkflowEditorPane("trigger"), { type: "trigger" });
assert.deepEqual(parseWorkflowEditorPane("step-0"), {
type: "step",
index: 0,
});
assert.deepEqual(parseWorkflowEditorPane("step-12"), {
type: "step",
index: 12,
});
});
test("rejects malformed pane parameters", () => {
for (const value of [undefined, null, "", "step", "step--1", "step-01"]) {
assert.equal(parseWorkflowEditorPane(value), null);
}
});
test("serializes panes for route search", () => {
assert.equal(serializeWorkflowEditorPane({ type: "trigger" }), "trigger");
assert.equal(
serializeWorkflowEditorPane({ type: "step", index: 3 }),
"step-3",
);
assert.equal(serializeWorkflowEditorPane(null), undefined);
});
@@ -0,0 +1,24 @@
export type WorkflowEditorPane =
| { type: "trigger" }
| { type: "step"; index: number }
| null;
const STEP_PANE_PATTERN = /^step-(0|[1-9]\d*)$/;
export function parseWorkflowEditorPane(value: unknown): WorkflowEditorPane {
if (value === "trigger") return { type: "trigger" };
if (typeof value !== "string") return null;
const match = STEP_PANE_PATTERN.exec(value);
if (!match) return null;
const index = Number(match[1]);
return Number.isSafeInteger(index) ? { type: "step", index } : null;
}
export function serializeWorkflowEditorPane(
pane: WorkflowEditorPane,
): string | undefined {
if (pane === null) return undefined;
return pane.type === "trigger" ? "trigger" : `step-${pane.index}`;
}
+40 -4
View File
@@ -26,6 +26,12 @@ async function createWorkflow(
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await dialog.getByRole("combobox", { name: "Channel" }).click();
await dialog
.getByTestId("channel-combobox-list")
.getByRole("button")
.first()
.click();
await dialog.getByLabel("Workflow name").fill(name);
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Delay" }).click();
@@ -106,7 +112,7 @@ test.fixme("direct forum thread links close back to the forum route", async ({
).toBeVisible();
});
test("direct workflow detail links close back to workflows", async ({
test("direct workflow editor links close back to workflows", async ({
page,
}) => {
const workflowName = `workflow_nav_${Date.now()}`;
@@ -123,15 +129,45 @@ test("direct workflow detail links close back to workflows", async ({
expect(workflowId).toBeTruthy();
await page.goto(`/#/workflows/${workflowId}`);
await page.goto(`/#/workflows/${workflowId}?pane=trigger`);
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
await page.getByRole("button", { name: "Close detail panel" }).click();
const editor = page.getByRole("dialog", { name: "Edit workflow" });
await expect(editor).toBeVisible();
await editor.getByRole("button", { name: "Close", exact: true }).click();
await expect(page).toHaveURL(/#\/workflows$/);
await expect(page.getByTestId("workflows-view")).toBeVisible();
});
test("back and forward traverse workflow inspector panes", async ({ page }) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog", { name: "Create workflow" });
const inspector = dialog.getByTestId("workflow-node-inspector");
await expect(inspector).toContainText("Trigger");
await expect.poll(() => new URL(page.url()).hash).toContain("pane=trigger");
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Delay" }).click();
await expect(inspector).toContainText("Step 1");
await expect.poll(() => new URL(page.url()).hash).toContain("pane=step-0");
await page.goBack();
await expect(inspector).toContainText("Trigger");
await expect.poll(() => new URL(page.url()).hash).toContain("pane=trigger");
await dialog.getByRole("button", { name: "Close inspector" }).click();
await expect(inspector).not.toBeVisible();
await expect.poll(() => new URL(page.url()).hash).not.toContain("pane=");
await page.goBack();
await expect(inspector).toContainText("Trigger");
await page.goForward();
await expect(inspector).not.toBeVisible();
});
test("forum reply deep links survive reload", async ({ page }) => {
await page.goto(
`/#/channels/${WATERCOLOR_CHANNEL_ID}/posts/${FORUM_POST_ID}?replyId=${FORUM_REPLY_ID}`,
+1 -1
View File
@@ -1051,5 +1051,5 @@ test("opens a workflow in the edit modal from its card", async ({ page }) => {
const dialog = page.getByRole("dialog", { name: "Edit workflow" });
await expect(dialog).toBeVisible();
await expect(dialog.getByLabel("Workflow name")).toHaveValue(workflowName);
await expect(page.getByTestId("workflow-detail-panel")).not.toBeVisible();
await expect(page).toHaveURL(/#\/workflows\/[^?]+\?pane=trigger/);
});