Polish workflow landing cards

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 14:12:37 -07:00
parent 0e6a04fafd
commit ac175010fa
7 changed files with 404 additions and 145 deletions
@@ -9,7 +9,7 @@ type WorkflowsRouteScreenProps = {
export function WorkflowsRouteScreen({
selectedWorkflowId,
}: WorkflowsRouteScreenProps) {
const { closeWorkflowDetail, goWorkflow } = useAppNavigation();
const { closeWorkflowDetail } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const memberChannels = channels.filter((channel) => channel.isMember);
@@ -18,9 +18,6 @@ export function WorkflowsRouteScreen({
<WorkflowsScreen
channels={memberChannels}
onCloseWorkflow={closeWorkflowDetail}
onSelectWorkflow={(workflowId) => {
void goWorkflow(workflowId);
}}
selectedWorkflowId={selectedWorkflowId}
/>
);
@@ -1,15 +1,27 @@
import {
Clock,
ArrowRight,
CalendarClock,
CircleCheckBig,
Clock3,
Copy,
GitPullRequest,
Hash,
MessageCircle,
MessageSquare,
MoreHorizontal,
Pencil,
Play,
Send,
SmilePlus,
Timer,
Trash2,
Webhook,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Workflow } from "@/shared/api/types";
import { Badge } from "@/shared/ui/badge";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
@@ -18,40 +30,69 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
getWorkflowCardLabel,
getWorkflowDescription,
getWorkflowDisplayStatus,
getWorkflowPrimaryAction,
getWorkflowTriggerSummary,
getWorkflowTriggerType,
} from "./workflowDefinition";
type WorkflowCardProps = {
workflow: Workflow;
channelName?: string;
isActive?: boolean;
onSelect: (workflowId: string) => void;
onTrigger: (workflowId: string) => void;
onEdit: (workflow: Workflow) => void;
onDuplicate: (workflow: Workflow) => void;
onDelete: (workflow: Workflow) => void;
};
function StatusBadge({ status }: { status: Workflow["status"] }) {
const variants: Record<
Workflow["status"],
"success" | "secondary" | "warning"
> = {
active: "success",
disabled: "secondary",
archived: "warning",
};
const TRIGGER_ICONS: Record<string, LucideIcon> = {
diff_posted: GitPullRequest,
message_posted: MessageSquare,
reaction_added: SmilePlus,
schedule: CalendarClock,
webhook: Webhook,
};
return <Badge variant={variants[status]}>{status}</Badge>;
const ACTION_ICONS: Record<string, LucideIcon> = {
add_reaction: SmilePlus,
call_webhook: Webhook,
delay: Timer,
request_approval: CircleCheckBig,
send_dm: MessageCircle,
send_message: Send,
set_channel_topic: Hash,
};
const TRIGGER_THEMES: Record<string, string> = {
diff_posted: "border-violet-400/30 bg-violet-600 text-white",
message_posted: "border-blue-400/30 bg-blue-600 text-white",
reaction_added: "border-pink-400/30 bg-pink-600 text-white",
schedule: "border-emerald-400/30 bg-emerald-600 text-white",
webhook: "border-orange-300/30 bg-orange-500 text-white",
};
function StatusBadge({ status }: { status: Workflow["status"] }) {
return (
<span
className={cn(
"rounded-full px-2 py-1 text-2xs font-semibold uppercase tracking-wider",
status === "active"
? "bg-white/20 text-white"
: "bg-black/20 text-white/80",
)}
>
{status}
</span>
);
}
export function WorkflowCard({
workflow,
channelName,
isActive = false,
onSelect,
onTrigger,
onEdit,
onDuplicate,
@@ -60,79 +101,121 @@ export function WorkflowCard({
const displayStatus = getWorkflowDisplayStatus(workflow);
const description = getWorkflowDescription(workflow.definition);
const triggerSummary = getWorkflowTriggerSummary(workflow.definition);
const triggerType = getWorkflowTriggerType(workflow.definition);
const actionType = getWorkflowPrimaryAction(workflow.definition);
const cardLabel = getWorkflowCardLabel(workflow.definition);
const TriggerIcon = triggerType ? TRIGGER_ICONS[triggerType] : undefined;
const ActionIcon = actionType ? ACTION_ICONS[actionType] : undefined;
const theme = triggerType ? TRIGGER_THEMES[triggerType] : undefined;
return (
<div
className={`relative w-full rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50 ${
isActive ? "border-primary/40 bg-primary/5 shadow-xs" : ""
}`}
className={cn(
"group relative min-h-60 w-full overflow-hidden rounded-2xl border p-5 text-left shadow-sm transition-all hover:-translate-y-0.5 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}`}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/10 via-transparent to-black/15"
/>
<button
className="absolute inset-0 rounded-lg"
onClick={() => onSelect(workflow.id)}
className="absolute inset-0 z-0 rounded-2xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white"
onClick={() => onEdit(workflow)}
type="button"
>
<span className="sr-only">View {workflow.name}</span>
<span className="sr-only">Edit {workflow.name}</span>
</button>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 shrink-0 text-amber-500" />
<span className="truncate text-sm font-medium">
{workflow.name}
<div className="pointer-events-none relative z-10 flex h-full min-h-48 flex-col">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2" aria-hidden="true">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/15 shadow-xs ring-1 ring-white/15">
{TriggerIcon ? (
<TriggerIcon className="h-5 w-5" />
) : (
<Zap className="h-5 w-5" />
)}
</span>
{ActionIcon ? (
<>
<ArrowRight className="h-4 w-4 text-white/60" />
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/15 shadow-xs ring-1 ring-white/15">
<ActionIcon className="h-5 w-5" />
</span>
</>
) : null}
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
<StatusBadge status={displayStatus} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Workflow actions"
className="h-8 w-8 text-white hover:bg-white/15 hover:text-white data-[state=open]:bg-white/15"
size="icon"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onTrigger(workflow.id)}>
<Play className="mr-2 h-4 w-4" />
Trigger
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(workflow)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDuplicate(workflow)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete(workflow)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-1.5 flex items-center gap-3 pl-6 text-2xs text-muted-foreground">
{channelName ? <span>{channelName}</span> : null}
{triggerSummary ? <span>{triggerSummary}</span> : null}
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
</span>
</div>
{description ? (
<p className="mt-2 pl-6 text-xs text-muted-foreground">
{description}
</p>
) : null}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Workflow actions"
className="relative z-10 h-7 w-7 shrink-0"
size="icon"
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onTrigger(workflow.id)}>
<Play className="mr-2 h-4 w-4" />
Trigger
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(workflow)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDuplicate(workflow)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete(workflow)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{triggerSummary ? (
<p className="mt-4 line-clamp-1 text-xs font-semibold text-white/70">
{triggerSummary}
</p>
) : null}
<h3 className="mt-1 line-clamp-4 text-xl font-bold leading-tight tracking-tight">
{cardLabel}
</h3>
{description ? (
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-white/75">
{description}
</p>
) : null}
<div className="mt-auto flex min-w-0 items-end justify-between gap-3 pt-5 text-white/75">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-white">
{workflow.name}
</p>
{channelName ? (
<p className="mt-0.5 truncate text-2xs">#{channelName}</p>
) : null}
</div>
<span className="flex shrink-0 items-center gap-1 text-2xs">
<Clock3 className="h-3.5 w-3.5" />
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
</span>
</div>
</div>
</div>
);
@@ -11,14 +11,12 @@ const WorkflowsView = React.lazy(async () => {
type WorkflowsScreenProps = {
channels: Channel[];
onCloseWorkflow: () => void;
onSelectWorkflow: (workflowId: string) => void;
selectedWorkflowId: string | null;
};
export function WorkflowsScreen({
channels,
onCloseWorkflow,
onSelectWorkflow,
selectedWorkflowId,
}: WorkflowsScreenProps) {
return (
@@ -27,7 +25,6 @@ export function WorkflowsScreen({
<WorkflowsView
channels={channels}
onCloseWorkflow={onCloseWorkflow}
onSelectWorkflow={onSelectWorkflow}
selectedWorkflowId={selectedWorkflowId}
/>
</React.Suspense>
@@ -17,13 +17,11 @@ import {
triggerWorkflow,
} from "@/shared/api/tauriWorkflows";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Skeleton } from "@/shared/ui/skeleton";
type WorkflowsViewProps = {
channels: Channel[];
onCloseWorkflow: () => void;
onSelectWorkflow: (workflowId: string) => void;
selectedWorkflowId: string | null;
};
@@ -40,28 +38,25 @@ type DialogState =
function WorkflowsListSkeleton() {
return (
<div className="space-y-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{["first", "second", "third", "fourth"].map((card) => (
<Card className="p-4" key={card}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full max-w-2xl" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
</div>
<div className="hidden shrink-0 gap-2 sm:flex">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-8 rounded-lg" />
<div
className="flex min-h-60 flex-col rounded-2xl border bg-card p-5"
key={card}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-9 rounded-xl" />
<Skeleton className="h-4 w-4" />
<Skeleton className="h-9 w-9 rounded-xl" />
</div>
<Skeleton className="h-6 w-16 rounded-full" />
</div>
</Card>
<Skeleton className="mt-5 h-3 w-28" />
<Skeleton className="mt-2 h-6 w-full" />
<Skeleton className="mt-2 h-6 w-4/5" />
<Skeleton className="mt-auto h-4 w-32" />
</div>
))}
</div>
);
@@ -70,7 +65,6 @@ function WorkflowsListSkeleton() {
export function WorkflowsView({
channels,
onCloseWorkflow,
onSelectWorkflow,
selectedWorkflowId,
}: WorkflowsViewProps) {
const [dialogState, setDialogState] = React.useState<DialogState>({
@@ -174,23 +168,28 @@ export function WorkflowsView({
data-testid="workflows-view"
>
<div
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4 pt-4"
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-5 pb-5 pt-5"
data-scroll-restoration-id="workflows-list"
>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Workflows</h2>
<Button
aria-label="Refresh workflows"
disabled={allWorkflowsQuery.isFetching}
onClick={() => void allWorkflowsQuery.refetch()}
size="icon"
variant="ghost"
>
<RefreshCw
className={`h-4 w-4 ${allWorkflowsQuery.isFetching ? "animate-spin" : ""}`}
/>
</Button>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold tracking-tight">Workflows</h2>
<Button
aria-label="Refresh workflows"
disabled={allWorkflowsQuery.isFetching}
onClick={() => void allWorkflowsQuery.refetch()}
size="icon"
variant="ghost"
>
<RefreshCw
className={`h-4 w-4 ${allWorkflowsQuery.isFetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Automations that keep your community moving.
</p>
</div>
<Button onClick={() => setDialogState({ mode: "create" })} size="sm">
<Plus className="mr-1 h-4 w-4" />
@@ -225,7 +224,13 @@ export function WorkflowsView({
</Button>
</div>
) : (
<div className="space-y-2">
<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"
}
>
{allWorkflows.map(({ workflow, channelName }) => (
<WorkflowCard
channelName={channelName}
@@ -234,7 +239,6 @@ export function WorkflowsView({
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onEdit={handleEdit}
onSelect={onSelectWorkflow}
onTrigger={handleTrigger}
workflow={workflow}
/>
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getWorkflowCardLabel } from "./workflowDefinition.ts";
test("builds a plain-language workflow card label", () => {
assert.equal(
getWorkflowCardLabel({
trigger: { on: "message_posted" },
steps: [{ action: "send_message" }],
}),
"When a message is posted, send a channel message",
);
assert.equal(
getWorkflowCardLabel({
trigger: { on: "reaction_added", emoji: "🔥" },
steps: [
{ action: "delay", duration: "5m" },
{ action: "add_reaction", emoji: "✅" },
],
}),
"When someone reacts with 🔥, wait 5m, then 1 more step",
);
});
test("summarizes common and custom schedules", () => {
assert.equal(
getWorkflowCardLabel({
trigger: { on: "schedule", interval: "15m" },
steps: [{ action: "call_webhook" }],
}),
"Every 15 minutes, call a webhook",
);
assert.equal(
getWorkflowCardLabel({
trigger: { on: "schedule", cron: "30 9 * * *" },
steps: [{ action: "request_approval" }],
}),
"Every day at 09:30 UTC, request approval",
);
assert.equal(
getWorkflowCardLabel({
trigger: { on: "schedule", cron: "*/5 8-17 * * 1-5" },
steps: [],
}),
"On a custom schedule",
);
});
test("gracefully labels definitions with future trigger and action types", () => {
assert.equal(
getWorkflowCardLabel({
trigger: { on: "issue_closed" },
steps: [{ action: "archive_issue" }],
}),
"When issue closed happens, archive issue",
);
assert.equal(getWorkflowCardLabel({}), "When this workflow starts");
});
@@ -1,5 +1,10 @@
import type { Workflow } from "@/shared/api/types";
import { TRIGGER_LABELS } from "./workflowFormTypes";
import {
scheduleFormFromTrigger,
SCHEDULE_FREQUENCY_LABELS,
} from "./workflowSchedule";
import { ACTION_LABELS, TRIGGER_LABELS } from "./workflowFormTypes";
import type { ActionType } from "./workflowFormTypes";
import type { TriggerType } from "./workflowFormTypes";
function asRecord(value: unknown): Record<string, unknown> | null {
@@ -9,6 +14,137 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
function humanizeIdentifier(value: string): string {
return value.replaceAll("_", " ").replace(/\s+/g, " ").trim();
}
function getWorkflowSteps(
definition: Record<string, unknown>,
): Record<string, unknown>[] {
return Array.isArray(definition.steps)
? definition.steps.map(asRecord).filter((step) => step !== null)
: [];
}
export function getWorkflowTriggerType(
definition: Record<string, unknown>,
): string | null {
return nonEmptyString(asRecord(definition.trigger)?.on);
}
export function getWorkflowPrimaryAction(
definition: Record<string, unknown>,
): string | null {
return nonEmptyString(getWorkflowSteps(definition)[0]?.action);
}
function getScheduleCardClause(trigger: Record<string, unknown>): string {
const schedule = scheduleFormFromTrigger({
on: "schedule",
cron: nonEmptyString(trigger.cron) ?? undefined,
interval: nonEmptyString(trigger.interval) ?? undefined,
});
switch (schedule.frequency) {
case "daily":
return `Every day at ${schedule.time} UTC`;
case "weekly":
return `Every week at ${schedule.time} UTC`;
case "monthly":
return `Every month at ${schedule.time} UTC`;
case "custom_interval":
return schedule.customInterval
? `Every ${schedule.customInterval}`
: "On a schedule";
case "custom_cron":
return "On a custom schedule";
default:
return SCHEDULE_FREQUENCY_LABELS[schedule.frequency];
}
}
function getTriggerCardClause(definition: Record<string, unknown>): string {
const trigger = asRecord(definition.trigger);
const triggerType = nonEmptyString(trigger?.on);
if (!trigger || !triggerType) return "When this workflow starts";
switch (triggerType) {
case "message_posted":
return nonEmptyString(trigger.filter)
? "When a matching message is posted"
: "When a message is posted";
case "reaction_added": {
const emoji = nonEmptyString(trigger.emoji);
return emoji
? `When someone reacts with ${emoji}`
: "When someone adds a reaction";
}
case "diff_posted":
return nonEmptyString(trigger.filter)
? "When a matching diff is posted"
: "When a diff is posted";
case "webhook":
return "When a webhook arrives";
case "schedule":
return getScheduleCardClause(trigger);
default:
return `When ${humanizeIdentifier(triggerType)} happens`;
}
}
function getActionCardClause(step: Record<string, unknown>): string | null {
const action = nonEmptyString(step.action);
if (!action) return null;
switch (action) {
case "delay": {
const duration = nonEmptyString(step.duration);
return duration ? `wait ${duration}` : "wait for a moment";
}
case "send_message":
return "send a channel message";
case "call_webhook":
return "call a webhook";
case "send_dm":
return "send a direct message";
case "request_approval":
return "request approval";
case "add_reaction": {
const emoji = nonEmptyString(step.emoji);
return emoji ? `add a ${emoji} reaction` : "add a reaction";
}
case "set_channel_topic":
return "update the channel topic";
default: {
const knownLabel = ACTION_LABELS[action as ActionType];
return (knownLabel ?? humanizeIdentifier(action)).toLocaleLowerCase();
}
}
}
/** Build a short plain-language label from the workflow's trigger and steps. */
export function getWorkflowCardLabel(
definition: Record<string, unknown>,
): string {
const triggerClause = getTriggerCardClause(definition);
const steps = getWorkflowSteps(definition);
const firstAction = steps[0] ? getActionCardClause(steps[0]) : null;
if (!firstAction) return triggerClause;
const remainingStepCount = steps.length - 1;
if (remainingStepCount === 0) return `${triggerClause}, ${firstAction}`;
return `${triggerClause}, ${firstAction}, then ${remainingStepCount} more ${
remainingStepCount === 1 ? "step" : "steps"
}`;
}
export function getWorkflowEnabled(
definition: Record<string, unknown>,
): boolean {
+8 -26
View File
@@ -551,6 +551,7 @@ test("captures disabled diff workflows in the list UI", async ({ page }) => {
.filter({ hasText: workflowName })
.first();
await expect(card).toContainText(workflowName);
await expect(card).toContainText("When a diff is posted, wait for a moment");
await expect(card).toContainText(description);
await expect(card).toContainText("Diff Posted");
await expect(card).toContainText("disabled");
@@ -653,35 +654,16 @@ test("deletes a workflow with confirmation", async ({ page }) => {
await expect(page.getByText("No workflows yet")).toBeVisible();
});
test("triggers a workflow from the detail panel", async ({ page }) => {
const workflowName = `trigger_test_${Date.now()}`;
test("opens a workflow in the edit modal from its card", async ({ page }) => {
const workflowName = `open_edit_test_${Date.now()}`;
await navigateToWorkflows(page);
await createWorkflow(page, workflowName);
// Click on the workflow card to open the detail panel
await page.getByRole("button", { name: `View ${workflowName}` }).click();
await expect(page.getByTestId("workflow-detail-panel")).toBeVisible();
await page.getByRole("button", { name: `Edit ${workflowName}` }).click();
// Click the Trigger button
await page
.getByTestId("workflow-detail-panel")
.getByRole("button", { name: "Trigger" })
.click();
// Wait for the trigger to complete (button text changes back from "Triggering...")
await expect(
page
.getByTestId("workflow-detail-panel")
.getByRole("button", { name: "Trigger" }),
).toBeVisible();
await expect(
page
.getByTestId("workflow-detail-panel")
.getByTestId("workflow-selected-run"),
).toBeVisible();
await expect(
page.getByTestId("workflow-detail-panel").getByTestId("workflow-run-trace"),
).toContainText("step_1");
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();
});