mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish workflow landing cards
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user