Files
buzz/desktop/src/features/workflows/ui/WorkflowCard.tsx
Taylor Ho 2dcb090b61 Polish workflow trigger filters
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-14 09:22:22 -07:00

413 lines
13 KiB
TypeScript

import {
ArrowRight,
CalendarClock,
CircleCheckBig,
Copy,
GitPullRequest,
Hash,
MessageCircle,
MessageSquare,
MoreHorizontal,
Pencil,
Play,
Power,
PowerOff,
Send,
SmilePlus,
Timer,
Trash2,
Webhook,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
import type { Workflow } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import {
getWorkflowCardLabel,
getWorkflowDescription,
getWorkflowDisplayStatus,
getWorkflowEnabled,
getWorkflowPrimaryAction,
getWorkflowPrimaryActionEmoji,
getWorkflowTriggerConfig,
getWorkflowTriggerSummary,
getWorkflowTriggerType,
} from "./workflowDefinition";
import { TriggerDescriptionText } from "./WorkflowNodeDescriptions";
import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation";
type WorkflowCardProps = {
workflow: Workflow;
channelName?: string;
isActive?: boolean;
isTogglingEnabled?: boolean;
onTrigger: (workflowId: string) => void;
onToggleEnabled: (workflow: Workflow) => void;
onEdit: (workflow: Workflow) => void;
onDuplicate: (workflow: Workflow) => void;
onDelete: (workflow: Workflow) => void;
};
const TRIGGER_ICONS: Record<string, LucideIcon> = {
diff_posted: GitPullRequest,
message_posted: MessageSquare,
reaction_added: SmilePlus,
schedule: CalendarClock,
webhook: Webhook,
};
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>
);
}
function ReactionGlyph({
className,
emoji,
testId,
url,
}: {
className: string;
emoji: string;
testId: string;
url?: string;
}) {
return url ? (
<img
alt={emoji}
className={cn("inline-block object-contain", className)}
data-testid={testId}
draggable={false}
src={rewriteRelayUrl(url)}
title={emojiDisplayName(emoji)}
/>
) : (
<span
className={cn(
"inline-flex items-center justify-center leading-none",
emoji.startsWith(":") && "text-3xs",
className,
)}
data-testid={testId}
title={emojiDisplayName(emoji)}
>
{emoji}
</span>
);
}
function ReactionLabelText({
reactions,
text,
}: {
reactions: ReadonlyArray<{ emoji: string; url?: string }>;
text: string;
}) {
const candidates = [...reactions].sort(
(left, right) => right.emoji.length - left.emoji.length,
);
const parts: React.ReactNode[] = [];
let cursor = 0;
while (cursor < text.length) {
let nextIndex = -1;
let nextReaction: (typeof candidates)[number] | undefined;
for (const reaction of candidates) {
const matchIndex = text.indexOf(reaction.emoji, cursor);
if (matchIndex >= 0 && (nextIndex < 0 || matchIndex < nextIndex)) {
nextIndex = matchIndex;
nextReaction = reaction;
}
}
if (!nextReaction) break;
if (nextIndex > cursor) parts.push(text.slice(cursor, nextIndex));
parts.push(
nextReaction.url ? (
<ReactionGlyph
className="h-5 w-5 align-text-bottom"
emoji={nextReaction.emoji}
key={`${nextReaction.emoji}-${nextIndex}`}
testId="workflow-card-reaction-emoji"
url={nextReaction.url}
/>
) : (
nextReaction.emoji
),
);
cursor = nextIndex + nextReaction.emoji.length;
}
if (cursor < text.length) parts.push(text.slice(cursor));
return parts.length > 0 ? parts : text;
}
function TriggerCardText({
authorLoading,
messageLoading,
reactions,
text,
}: {
authorLoading?: boolean;
messageLoading?: boolean;
reactions: ReadonlyArray<{ emoji: string; url?: string }>;
text: string;
}) {
return authorLoading || messageLoading ? (
<TriggerDescriptionText
authorLoading={authorLoading}
messageLoading={messageLoading}
text={text}
/>
) : (
<ReactionLabelText reactions={reactions} text={text} />
);
}
export function WorkflowCard({
workflow,
channelName,
isActive = false,
isTogglingEnabled = false,
onTrigger,
onToggleEnabled,
onEdit,
onDuplicate,
onDelete,
}: WorkflowCardProps) {
const customEmoji = useCustomEmoji();
const isEnabled = getWorkflowEnabled(workflow.definition);
const displayStatus = getWorkflowDisplayStatus(workflow);
const description = getWorkflowDescription(workflow.definition);
const configuredTrigger = getWorkflowTriggerConfig(workflow.definition);
const trigger = configuredTrigger ?? { on: "message_posted" as const };
const triggerPresentation = useWorkflowTriggerPresentation({
trigger,
workflowChannelId: workflow.channelId,
});
const triggerSummary =
configuredTrigger &&
["diff_posted", "message_posted", "reaction_added"].includes(trigger.on)
? triggerPresentation.description
: getWorkflowTriggerSummary(workflow.definition);
const triggerType = getWorkflowTriggerType(workflow.definition);
const actionType = getWorkflowPrimaryAction(workflow.definition);
const actionReaction = getWorkflowPrimaryActionEmoji(workflow.definition);
const cardLabel = getWorkflowCardLabel(workflow.definition, {
triggerDescription:
configuredTrigger &&
triggerPresentation.description !==
getWorkflowTriggerSummary({ trigger: { on: configuredTrigger.on } })
? triggerPresentation.description
: undefined,
triggerReaction: triggerPresentation.emoji,
});
const cardReactions = [
...new Set([triggerPresentation.emoji, actionReaction]),
]
.filter((emoji): emoji is string => Boolean(emoji))
.map((emoji) => ({
emoji,
url: reactionEmojiUrl(emoji, customEmoji),
}));
const TriggerIcon = triggerType ? TRIGGER_ICONS[triggerType] : undefined;
const ActionIcon = actionType ? ACTION_ICONS[actionType] : undefined;
const triggerReaction = cardReactions.find(
({ emoji }) => emoji === triggerPresentation.emoji,
);
const actionReactionPresentation = cardReactions.find(
({ emoji }) => emoji === actionReaction,
);
const theme = triggerType ? TRIGGER_THEMES[triggerType] : undefined;
return (
<div
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}`}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/10 via-transparent to-black/15 transition-colors duration-200 group-hover:bg-white/5"
/>
<button
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">Edit {workflow.name}</span>
</button>
<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">
{triggerReaction ? (
<ReactionGlyph
className="h-6 w-6 text-2xl"
emoji={triggerReaction.emoji}
testId="workflow-card-trigger-reaction"
url={triggerReaction.url}
/>
) : 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">
{actionReactionPresentation ? (
<ReactionGlyph
className="h-6 w-6 text-2xl"
emoji={actionReactionPresentation.emoji}
testId="workflow-card-action-reaction"
url={actionReactionPresentation.url}
/>
) : (
<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
disabled={isTogglingEnabled}
onClick={() => onToggleEnabled(workflow)}
>
{isEnabled ? (
<PowerOff className="mr-2 h-4 w-4" />
) : (
<Power className="mr-2 h-4 w-4" />
)}
{isEnabled ? "Disable" : "Enable"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete(workflow)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{triggerSummary ? (
<p className="mt-4 line-clamp-1 text-xs font-semibold text-white/70">
<TriggerCardText
authorLoading={triggerPresentation.authorLoading}
messageLoading={triggerPresentation.messageLoading}
reactions={cardReactions}
text={triggerSummary}
/>
</p>
) : null}
<h3 className="mt-1 line-clamp-4 text-xl font-bold leading-tight tracking-tight">
<TriggerCardText
authorLoading={triggerPresentation.authorLoading}
messageLoading={triggerPresentation.messageLoading}
reactions={cardReactions}
text={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="shrink-0 text-2xs">
{new Date(workflow.updatedAt * 1000).toLocaleDateString()}
</span>
</div>
</div>
</div>
);
}