Route workflow editor modals

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-14 10:56:26 -07:00
parent 3fd80977f6
commit 8b7948c507
10 changed files with 183 additions and 589 deletions
+31 -2
View File
@@ -161,6 +161,33 @@ export function useAppNavigation() {
[commitNavigation],
);
const goNewWorkflow = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/workflows",
search: { view: "create" },
},
behavior,
),
[commitNavigation],
);
const goDuplicateWorkflow = React.useCallback(
(workflowId: string, behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/workflows/$workflowId",
params: {
workflowId,
},
search: { view: "duplicate" },
},
behavior,
),
[commitNavigation],
);
const goChannel = React.useCallback(
(
channelId: string,
@@ -266,7 +293,7 @@ export function useAppNavigation() {
void goHome({ replace: true });
}, [canGoBack, goHome, router.history]);
const closeWorkflowDetail = React.useCallback(() => {
const closeWorkflowEditor = React.useCallback(() => {
if (canGoBack) {
router.history.back();
return;
@@ -313,11 +340,13 @@ export function useAppNavigation() {
return {
closeForumPost,
closeSettings,
closeWorkflowDetail,
closeWorkflowEditor,
goAgents,
goChannel,
goForumPost,
goHome,
goDuplicateWorkflow,
goNewWorkflow,
goNewMessage,
goProject,
goProjects,
@@ -1,15 +1,23 @@
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen";
import {
type WorkflowEditorRoute,
WorkflowsScreen,
} from "@/features/workflows/ui/WorkflowsScreen";
type WorkflowsRouteScreenProps = {
selectedWorkflowId: string | null;
editor?: WorkflowEditorRoute | null;
};
export function WorkflowsRouteScreen({
selectedWorkflowId,
editor = null,
}: WorkflowsRouteScreenProps) {
const { closeWorkflowDetail } = useAppNavigation();
const {
closeWorkflowEditor,
goDuplicateWorkflow,
goNewWorkflow,
goWorkflow,
} = useAppNavigation();
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const memberChannels = channels.filter((channel) => channel.isMember);
@@ -17,8 +25,17 @@ export function WorkflowsRouteScreen({
return (
<WorkflowsScreen
channels={memberChannels}
onCloseWorkflow={closeWorkflowDetail}
selectedWorkflowId={selectedWorkflowId}
editor={editor}
onCloseEditor={closeWorkflowEditor}
onCreateWorkflow={() => {
void goNewWorkflow();
}}
onDuplicateWorkflow={(workflowId) => {
void goDuplicateWorkflow(workflowId);
}}
onEditWorkflow={(workflowId) => {
void goWorkflow(workflowId);
}}
/>
);
}
@@ -5,7 +5,10 @@ import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows/$workflowId")({
component: WorkflowDetailRouteComponent,
component: WorkflowEditorRouteComponent,
validateSearch: (search: Record<string, unknown>) => ({
view: search.view === "duplicate" ? search.view : undefined,
}),
});
const WorkflowsRouteScreen = React.lazy(async () => {
@@ -13,13 +16,19 @@ const WorkflowsRouteScreen = React.lazy(async () => {
return { default: module.WorkflowsRouteScreen };
});
function WorkflowDetailRouteComponent() {
function WorkflowEditorRouteComponent() {
usePreviewFeatureWarning("workflows");
const { workflowId } = Route.useParams();
const { view } = Route.useSearch();
return (
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsRouteScreen selectedWorkflowId={workflowId} />
<WorkflowsRouteScreen
editor={{
mode: view === "duplicate" ? "duplicate" : "edit",
workflowId,
}}
/>
</React.Suspense>
);
}
+8 -1
View File
@@ -6,6 +6,9 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows")({
component: WorkflowsRouteComponent,
validateSearch: (search: Record<string, unknown>) => ({
view: search.view === "create" ? search.view : undefined,
}),
});
const WorkflowsRouteScreen = React.lazy(async () => {
@@ -15,9 +18,13 @@ const WorkflowsRouteScreen = React.lazy(async () => {
function WorkflowsRouteComponent() {
usePreviewFeatureWarning("workflows");
const { view } = Route.useSearch();
return (
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsRouteScreen selectedWorkflowId={null} />
<WorkflowsRouteScreen
editor={view === "create" ? { mode: "create" } : null}
/>
</React.Suspense>
);
}
@@ -52,7 +52,6 @@ import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation
type WorkflowCardProps = {
workflow: Workflow;
channelName?: string;
isActive?: boolean;
isTogglingEnabled?: boolean;
onTrigger: (workflowId: string) => void;
onToggleEnabled: (workflow: Workflow) => void;
@@ -208,7 +207,6 @@ function TriggerCardText({
export function WorkflowCard({
workflow,
channelName,
isActive = false,
isTogglingEnabled = false,
onTrigger,
onToggleEnabled,
@@ -266,8 +264,6 @@ export function WorkflowCard({
className={cn(
"group relative min-h-60 w-full overflow-hidden rounded-2xl border p-5 text-left shadow-sm transition-shadow duration-200 hover:shadow-lg",
theme ?? "border-slate-500/30 bg-slate-700 text-white",
isActive &&
"ring-2 ring-primary ring-offset-2 ring-offset-background shadow-lg",
)}
data-testid={`workflow-card-${workflow.id}`}
>
@@ -1,375 +0,0 @@
import { ChevronDown, ChevronRight, Pencil, Play, X } from "lucide-react";
import * as React from "react";
import {
useRunApprovalsQuery,
useTriggerWorkflowMutation,
useWorkflowQuery,
useWorkflowRunsQuery,
} from "@/features/workflows/hooks";
import { WorkflowRunTrace } from "@/features/workflows/ui/WorkflowRunTrace";
import type { Workflow } from "@/shared/api/types";
import { Badge, type BadgeProps } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import {
getWorkflowDescription,
getWorkflowDisplayStatus,
getWorkflowTriggerSummary,
} from "./workflowDefinition";
type WorkflowDetailPanelProps = {
workflowId: string;
onClose: () => void;
onEdit: (workflow: Workflow) => void;
};
export function WorkflowDetailPanel({
workflowId,
onClose,
onEdit,
}: WorkflowDetailPanelProps) {
const workflowQuery = useWorkflowQuery(workflowId);
const runsQuery = useWorkflowRunsQuery(workflowId);
const triggerMutation = useTriggerWorkflowMutation(workflowId);
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
const workflow = workflowQuery.data;
const runs = runsQuery.data ?? [];
const approvalsQuery = useRunApprovalsQuery(workflowId, selectedRunId);
const workflowDescription = workflow
? getWorkflowDescription(workflow.definition)
: null;
const triggerSummary = workflow
? getWorkflowTriggerSummary(workflow.definition)
: null;
const workflowStatus = workflow ? getWorkflowDisplayStatus(workflow) : null;
const triggerError = errorMessage(
triggerMutation.error,
"The relay did not create a workflow run.",
);
const runsError = errorMessage(
runsQuery.error,
"Run history could not be loaded.",
);
const selectedRunIsPendingHistory =
selectedRunId !== null && !runs.some((run) => run.id === selectedRunId);
async function handleTrigger() {
try {
const response = await triggerMutation.mutateAsync();
setSelectedRunId(response.runId);
} catch {
// React Query stores the error; keep the current selection unchanged.
}
}
return (
<div
className="flex h-full flex-col border-l bg-background pt-4"
data-testid="workflow-detail-panel"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
{workflow ? (
<h3 className="truncate text-sm font-semibold">
{workflow.name}
</h3>
) : (
<Skeleton className="h-4 w-36" />
)}
{workflowStatus ? <RunStatusBadge status={workflowStatus} /> : null}
</div>
{workflowDescription ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{workflowDescription}
</p>
) : workflowQuery.isLoading ? (
<Skeleton className="mt-1 h-3 w-full max-w-64" />
) : null}
{triggerSummary ? (
<p className="mt-1 truncate text-xs text-muted-foreground">
{triggerSummary}
</p>
) : workflowQuery.isLoading ? (
<Skeleton className="mt-1 h-3 w-40" />
) : null}
</div>
<div className="flex items-center gap-1">
{workflow ? (
<Button
onClick={() => onEdit(workflow)}
size="sm"
variant="outline"
>
<Pencil className="mr-1 h-4 w-4" />
Edit
</Button>
) : null}
<Button
disabled={triggerMutation.isPending || workflowQuery.isLoading}
onClick={() => void handleTrigger()}
size="sm"
variant="outline"
>
<Play className="mr-1 h-4 w-4" />
{triggerMutation.isPending ? "Triggering..." : "Trigger"}
</Button>
<Button
aria-label="Close detail panel"
onClick={onClose}
size="icon"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{triggerMutation.isError ? (
<div
className="border-b px-4 py-2 text-xs text-destructive"
role="alert"
>
<p className="font-medium">Failed to trigger workflow</p>
<p className="mt-1 break-words text-muted-foreground">
{triggerError}
</p>
</div>
) : null}
<div
className="flex-1 overflow-y-auto"
data-scroll-restoration-id={`workflow-detail:${workflowId}`}
>
{workflow ? (
<div className="space-y-4 p-4">
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Definition
</h4>
<pre className="max-h-64 overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs leading-relaxed">
{JSON.stringify(workflow.definition, null, 2)}
</pre>
</div>
<div>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Run History
</h4>
{runsQuery.isError ? (
<div
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
role="alert"
>
<p className="font-medium">Failed to load run history</p>
<p className="mt-1 break-words">{runsError}</p>
</div>
) : runsQuery.isLoading ? (
<div
className="space-y-2"
aria-label="Loading run history"
role="status"
>
<Skeleton className="h-16 w-full rounded-xl" />
</div>
) : selectedRunIsPendingHistory ? (
<div
className="rounded-lg border border-primary/30 bg-primary/10 px-3 py-2 text-xs"
data-testid="workflow-run-created"
role="status"
>
<p className="font-medium">Run created</p>
<p className="mt-1 break-all font-mono text-muted-foreground">
{selectedRunId}
</p>
<p className="mt-1 text-muted-foreground">
Waiting for its persisted trace…
</p>
</div>
) : runs.length === 0 ? (
<p className="text-sm text-muted-foreground">No runs yet.</p>
) : (
<div className="space-y-2">
{runs.map((run) => {
const isSelected = selectedRunId === run.id;
const duration = formatRunDuration(
run.startedAt,
run.completedAt,
);
const failureReason = workflowRunFailureReason(
run.errorCode,
run.errorMessage,
);
return (
<div
className={`overflow-hidden rounded-xl border bg-card/70 transition-colors ${
isSelected
? "border-primary/40 bg-primary/5 shadow-xs"
: "border-border/70 hover:bg-muted/20"
}`}
key={run.id}
>
<button
aria-expanded={isSelected}
className="w-full px-4 py-3 text-left"
data-testid={
isSelected ? "workflow-selected-run" : undefined
}
onClick={() =>
setSelectedRunId(isSelected ? null : run.id)
}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{isSelected ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="truncate font-mono text-xs font-medium">
{run.id.slice(0, 8)}
</span>
<RunStatusBadge status={run.status} />
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-2xs text-muted-foreground">
<span>
{new Date(
run.createdAt * 1000,
).toLocaleString()}
</span>
<span>
{run.executionTrace.length}{" "}
{run.executionTrace.length === 1
? "step"
: "steps"}
</span>
{duration ? <span>{duration}</span> : null}
{run.currentStep !== null ? (
<span>
Current step {run.currentStep + 1}
</span>
) : null}
</div>
{failureReason ? (
<p className="mt-2 break-words pl-6 text-xs text-destructive">
{failureReason}
</p>
) : null}
</div>
</div>
</button>
{isSelected ? (
<div className="border-t border-border/60 bg-background/60 px-4 py-4">
<div className="mb-3 flex items-center gap-2 text-2xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
<span>Execution Trace</span>
{approvalsQuery.isFetching ? (
<span className="text-2xs tracking-[0.12em] text-muted-foreground/80">
Refreshing approvals...
</span>
) : null}
</div>
{approvalsQuery.error instanceof Error ? (
<p className="mb-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{approvalsQuery.error.message}
</p>
) : null}
<WorkflowRunTrace
approvals={approvalsQuery.data}
run={run}
/>
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
</div>
) : workflowQuery.isError ? (
<div className="flex h-32 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">Failed to load workflow</p>
</div>
) : (
<div className="space-y-4 p-4">
<div>
<Skeleton className="mb-2 h-4 w-28" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
<div>
<Skeleton className="mb-2 h-4 w-24" />
<div className="space-y-2">
<Skeleton className="h-16 w-full rounded-xl" />
<Skeleton className="h-16 w-full rounded-xl" />
</div>
</div>
</div>
)}
</div>
</div>
);
}
function workflowRunFailureReason(
errorCode: string | null,
diagnostic: string | null,
) {
if (diagnostic?.trim()) return diagnostic;
if (!errorCode) return null;
const knownReasons: Record<string, string> = {
approval_denied: "Approval was denied.",
approval_expired: "Approval expired before the workflow could continue.",
external_outcome_unknown:
"The external action may have completed, but its outcome could not be confirmed.",
run_interrupted: "The run was interrupted before it could finish.",
};
return (
knownReasons[errorCode] ?? `Run failed (${errorCode.replace(/_/g, " ")}).`
);
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message.trim().length > 0
? error.message
: fallback;
}
function formatRunDuration(
startedAt: number | null,
completedAt: number | null,
) {
if (startedAt === null || completedAt === null) return null;
const seconds = completedAt - startedAt;
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
return `${seconds.toFixed(1)}s`;
}
function formatStatusLabel(status: string) {
return status.replace(/_/g, " ");
}
function RunStatusBadge({ status }: { status: string }) {
const variants: Record<string, BadgeProps["variant"]> = {
active: "success",
disabled: "secondary",
archived: "warning",
completed: "success",
failed: "destructive",
running: "info",
pending: "secondary",
cancelled: "secondary",
waiting_approval: "warning",
};
return (
<Badge variant={variants[status] ?? "secondary"}>
{formatStatusLabel(status)}
</Badge>
);
}
@@ -1,5 +1,6 @@
import * as React from "react";
import { Code } from "lucide-react";
import { useBlocker } from "@tanstack/react-router";
import { stringify as yamlStringify } from "yaml";
import {
@@ -113,6 +114,8 @@ export function WorkflowDialog({
channelId,
yaml: getInitialYaml(mode, workflow),
});
const allowNavigationRef = React.useRef(false);
const proceedingNavigationRef = React.useRef(false);
const createMutation = useCreateWorkflowMutation(selectedChannelId);
const updateMutation = useUpdateWorkflowMutation(workflow?.id ?? "");
@@ -156,6 +159,17 @@ export function WorkflowDialog({
const isDirty =
yamlDefinition !== initialValuesRef.current.yaml ||
selectedChannelId !== initialValuesRef.current.channelId;
const navigationBlocker = useBlocker({
enableBeforeUnload: isDirty,
shouldBlockFn: () => isDirty && !allowNavigationRef.current,
withResolver: true,
});
React.useEffect(() => {
if (navigationBlocker.status === "blocked") {
setDiscardConfirmationOpen(true);
}
}, [navigationBlocker.status]);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
@@ -175,7 +189,6 @@ export function WorkflowDialog({
try {
const saved = await mutation.mutateAsync(yamlDefinition);
closeDialog();
if (saved.webhookSecret) {
const relayHttpUrl = await getRelayHttpUrl();
setSavedWebhookInfo({
@@ -183,6 +196,9 @@ export function WorkflowDialog({
webhookSecret: saved.webhookSecret,
workflowId: saved.workflow.id,
});
} else {
allowNavigationRef.current = true;
closeDialog();
}
} catch {
// React Query stores the error; keep the dialog open.
@@ -220,7 +236,10 @@ export function WorkflowDialog({
return (
<>
<Dialog onOpenChange={handleOpenChange} open={open}>
<Dialog
onOpenChange={handleOpenChange}
open={open && savedWebhookInfo === null}
>
<DialogContent className="flex h-[88vh] max-h-[88vh] w-[calc(100vw-2rem)] max-w-6xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="flex flex-shrink-0 flex-row items-center justify-between gap-6 border-b border-border px-6 py-5 pr-14 text-left">
<div className="space-y-1.5">
@@ -333,7 +352,16 @@ export function WorkflowDialog({
</Dialog>
<AlertDialog
onOpenChange={setDiscardConfirmationOpen}
onOpenChange={(nextOpen) => {
setDiscardConfirmationOpen(nextOpen);
if (
!nextOpen &&
navigationBlocker.status === "blocked" &&
!proceedingNavigationRef.current
) {
navigationBlocker.reset();
}
}}
open={discardConfirmationOpen}
>
<AlertDialogContent>
@@ -350,7 +378,20 @@ export function WorkflowDialog({
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={closeDialog} type="button" variant="destructive">
<Button
onClick={() => {
setDiscardConfirmationOpen(false);
if (navigationBlocker.status === "blocked") {
proceedingNavigationRef.current = true;
navigationBlocker.proceed();
return;
}
allowNavigationRef.current = true;
closeDialog();
}}
type="button"
variant="destructive"
>
Discard changes
</Button>
</AlertDialogAction>
@@ -362,7 +403,8 @@ export function WorkflowDialog({
<WorkflowWebhookSecretDialog
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setSavedWebhookInfo(null);
allowNavigationRef.current = true;
closeDialog();
}
}}
open
@@ -1,128 +0,0 @@
import { Check, Clock, SkipForward, X } from "lucide-react";
import type { WorkflowApproval, WorkflowRun } from "@/shared/api/types";
import { Badge, type BadgeProps } from "@/shared/ui/badge";
import { WorkflowApprovalCard } from "@/features/workflows/ui/WorkflowApprovalCard";
type WorkflowRunTraceProps = {
run: WorkflowRun;
approvals?: WorkflowApproval[];
};
function formatStatusLabel(status: string) {
return status.replace(/_/g, " ");
}
function StepStatusBadge({ status }: { status: string }) {
const variants: Record<string, BadgeProps["variant"]> = {
completed: "success",
failed: "destructive",
error: "destructive",
running: "info",
pending: "secondary",
cancelled: "secondary",
skipped: "secondary",
waiting_approval: "warning",
};
return (
<Badge variant={variants[status] ?? "secondary"}>
{formatStatusLabel(status)}
</Badge>
);
}
function StepStatusIcon({ status }: { status: string }) {
switch (status) {
case "completed":
return <Check className="h-4 w-4 text-green-500" />;
case "failed":
case "error":
return <X className="h-4 w-4 text-red-500" />;
case "skipped":
return <SkipForward className="h-4 w-4 text-muted-foreground" />;
case "waiting_approval":
return <Clock className="h-4 w-4 text-amber-500" />;
default:
return <Clock className="h-4 w-4 text-blue-500" />;
}
}
function formatDuration(startedAt: number | null, completedAt: number | null) {
if (startedAt === null || completedAt === null) return null;
const seconds = completedAt - startedAt;
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
return `${seconds.toFixed(1)}s`;
}
export function WorkflowRunTrace({
run,
approvals = [],
}: WorkflowRunTraceProps) {
if (run.executionTrace.length === 0) {
return (
<p className="rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6 text-center text-sm text-muted-foreground">
No steps recorded yet.
</p>
);
}
return (
<div className="space-y-3" data-testid="workflow-run-trace">
{run.executionTrace.map((step) => {
const duration = formatDuration(step.startedAt, step.completedAt);
const pendingApproval = approvals.find(
(a) => a.stepId === step.stepId && a.status === "pending",
);
return (
<div
className="rounded-xl border border-border/60 bg-background/80 p-3 shadow-xs"
key={step.stepId}
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<StepStatusIcon status={step.status} />
<span className="min-w-0 flex-1 truncate font-mono text-xs font-medium">
{step.stepId}
</span>
<StepStatusBadge status={step.status} />
{duration ? (
<span className="text-xs text-muted-foreground">
{duration}
</span>
) : null}
</div>
{Object.keys(step.output).length > 0 ? (
<div className="mt-3">
<p className="mb-1 text-2xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Output
</p>
<pre className="max-h-32 overflow-auto rounded-lg bg-muted/40 px-3 py-2 font-mono text-xs text-muted-foreground">
{JSON.stringify(step.output, null, 2)}
</pre>
</div>
) : null}
{step.error ? (
<div className="mt-3">
<p className="mb-1 text-2xs font-medium uppercase tracking-[0.16em] text-red-400">
Error
</p>
<pre className="max-h-32 overflow-auto rounded-lg bg-red-500/10 px-3 py-2 font-mono text-xs text-red-400">
{step.error}
</pre>
</div>
) : null}
{pendingApproval ? (
<div className="mt-3">
<p className="mb-2 text-2xs font-medium uppercase tracking-[0.16em] text-amber-600">
Pending approval
</p>
<WorkflowApprovalCard approval={pendingApproval} />
</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -8,24 +8,37 @@ const WorkflowsView = React.lazy(async () => {
return { default: module.WorkflowsView };
});
export type WorkflowEditorRoute =
| { mode: "create" }
| { mode: "duplicate" | "edit"; workflowId: string };
type WorkflowsScreenProps = {
channels: Channel[];
onCloseWorkflow: () => void;
selectedWorkflowId: string | null;
editor: WorkflowEditorRoute | null;
onCloseEditor: () => void;
onCreateWorkflow: () => void;
onDuplicateWorkflow: (workflowId: string) => void;
onEditWorkflow: (workflowId: string) => void;
};
export function WorkflowsScreen({
channels,
onCloseWorkflow,
selectedWorkflowId,
editor,
onCloseEditor,
onCreateWorkflow,
onDuplicateWorkflow,
onEditWorkflow,
}: WorkflowsScreenProps) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<WorkflowsView
channels={channels}
onCloseWorkflow={onCloseWorkflow}
selectedWorkflowId={selectedWorkflowId}
editor={editor}
onCloseEditor={onCloseEditor}
onCreateWorkflow={onCreateWorkflow}
onDuplicateWorkflow={onDuplicateWorkflow}
onEditWorkflow={onEditWorkflow}
/>
</React.Suspense>
</div>
@@ -5,12 +5,13 @@ import { stringify as yamlStringify } from "yaml";
import {
allWorkflowsQueryKey,
useWorkflowQuery,
workflowListFocusRefetchPolicy,
} from "@/features/workflows/hooks";
import { WorkflowCard } from "@/features/workflows/ui/WorkflowCard";
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
import { WorkflowDetailPanel } from "@/features/workflows/ui/WorkflowDetailPanel";
import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
import type { WorkflowEditorRoute } from "@/features/workflows/ui/WorkflowsScreen";
import {
getWorkflowEnabled,
withWorkflowEnabled,
@@ -28,8 +29,11 @@ import { Skeleton } from "@/shared/ui/skeleton";
type WorkflowsViewProps = {
channels: Channel[];
onCloseWorkflow: () => void;
selectedWorkflowId: string | null;
editor: WorkflowEditorRoute | null;
onCloseEditor: () => void;
onCreateWorkflow: () => void;
onDuplicateWorkflow: (workflowId: string) => void;
onEditWorkflow: (workflowId: string) => void;
};
type WorkflowWithChannel = {
@@ -37,12 +41,6 @@ type WorkflowWithChannel = {
channelName: string;
};
type DialogState =
| { mode: "closed" }
| { mode: "create" }
| { mode: "edit"; workflow: Workflow }
| { mode: "duplicate"; workflow: Workflow };
function WorkflowsListSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
@@ -85,15 +83,19 @@ function CreateWorkflowCard({ onClick }: { onClick: () => void }) {
export function WorkflowsView({
channels,
onCloseWorkflow,
selectedWorkflowId,
editor,
onCloseEditor,
onCreateWorkflow,
onDuplicateWorkflow,
onEditWorkflow,
}: WorkflowsViewProps) {
const [dialogState, setDialogState] = React.useState<DialogState>({
mode: "closed",
});
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
const queryClient = useQueryClient();
const editorWorkflowId =
editor && editor.mode !== "create" ? editor.workflowId : null;
const editorWorkflowQuery = useWorkflowQuery(editorWorkflowId);
const memberChannels = channels.filter((c) => c.isMember);
const channelIds = memberChannels.map((c) => c.id).sort();
const channelIdKey = channelIds.join(",");
@@ -135,10 +137,7 @@ export function WorkflowsView({
const deleteMutation = useMutation({
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
onSuccess: (_data, workflowId) => {
if (selectedWorkflowId === workflowId) {
onCloseWorkflow();
}
onSuccess: () => {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "workflows" ||
@@ -188,13 +187,13 @@ export function WorkflowsView({
);
const handleEdit = React.useCallback(
(workflow: Workflow) => setDialogState({ mode: "edit", workflow }),
[],
(workflow: Workflow) => onEditWorkflow(workflow.id),
[onEditWorkflow],
);
const handleDuplicate = React.useCallback(
(workflow: Workflow) => setDialogState({ mode: "duplicate", workflow }),
[],
(workflow: Workflow) => onDuplicateWorkflow(workflow.id),
[onDuplicateWorkflow],
);
const toggleEnabled = toggleEnabledMutation.mutate;
@@ -203,11 +202,11 @@ export function WorkflowsView({
[toggleEnabled],
);
const handleDialogOpenChange = React.useCallback((open: boolean) => {
if (!open) {
setDialogState({ mode: "closed" });
}
}, []);
const editorWorkflow =
allWorkflows.find(({ workflow }) => workflow.id === editorWorkflowId)
?.workflow ?? editorWorkflowQuery.data;
const canOpenEditor =
editor?.mode === "create" || editorWorkflow !== undefined;
return (
<div
@@ -251,20 +250,11 @@ export function WorkflowsView({
</Button>
</div>
) : (
<div
className={
selectedWorkflowId
? "grid grid-cols-1 gap-3 xl:grid-cols-2"
: "grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
}
>
<CreateWorkflowCard
onClick={() => setDialogState({ mode: "create" })}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<CreateWorkflowCard onClick={onCreateWorkflow} />
{allWorkflows.map(({ workflow, channelName }) => (
<WorkflowCard
channelName={channelName}
isActive={selectedWorkflowId === workflow.id}
isTogglingEnabled={
toggleEnabledMutation.isPending &&
toggleEnabledMutation.variables?.id === workflow.id
@@ -283,29 +273,23 @@ export function WorkflowsView({
</div>
</div>
{selectedWorkflowId ? (
<div className="w-[400px] shrink-0">
<WorkflowDetailPanel
key={selectedWorkflowId}
onClose={onCloseWorkflow}
onEdit={handleEdit}
workflowId={selectedWorkflowId}
/>
</div>
{editor && canOpenEditor ? (
<WorkflowDialog
channels={memberChannels}
key={
editor.mode === "create"
? editor.mode
: `${editor.mode}:${editor.workflowId}`
}
mode={editor.mode}
onOpenChange={(open) => {
if (!open) onCloseEditor();
}}
open
workflow={editorWorkflow}
/>
) : null}
<WorkflowDialog
channels={memberChannels}
mode={dialogState.mode === "closed" ? "create" : dialogState.mode}
onOpenChange={handleDialogOpenChange}
open={dialogState.mode !== "closed"}
workflow={
dialogState.mode === "edit" || dialogState.mode === "duplicate"
? dialogState.workflow
: null
}
/>
<WorkflowDeleteDialog
onConfirm={handleConfirmDelete}
onOpenChange={(open) => {