Show workflow step details

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 23:27:19 -07:00
parent 4a5bff2614
commit 7075eba33e
6 changed files with 358 additions and 113 deletions
@@ -5,7 +5,6 @@ import {
Check,
ChevronDown,
GitPullRequest,
LoaderCircle,
MessageSquare,
Plus,
SmilePlus,
@@ -38,8 +37,11 @@ import {
import { Input } from "@/shared/ui/input";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
import {
StepReactionDescription,
TriggerNodeDescription,
} from "./WorkflowNodeDescriptions";
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
import { WorkflowStepCard } from "./WorkflowStepCard";
import {
@@ -66,10 +68,8 @@ import type {
WorkflowFormState,
} from "./workflowFormTypes";
import { defaultScheduleTrigger } from "./workflowSchedule";
import {
TRIGGER_MESSAGE_LOADING_LABEL,
workflowTriggerDescription,
} from "./workflowTriggerDescription";
import { workflowTriggerDescription } from "./workflowTriggerDescription";
import { workflowStepDescription } from "./workflowStepDescription";
const TRIGGER_ICONS: Record<TriggerType, LucideIcon> = {
diff_posted: GitPullRequest,
@@ -390,90 +390,6 @@ function WorkflowNode({
);
}
function TriggerNodeDescription({
authorAvatarUrl,
authorLabel,
description,
messageLoading,
}: {
authorAvatarUrl?: string | null;
authorLabel?: string | null;
description: string;
messageLoading?: boolean;
}) {
const authorIndex = authorLabel ? description.lastIndexOf(authorLabel) : -1;
if (!authorLabel || authorIndex < 0) {
return (
<TriggerDescriptionText
messageLoading={messageLoading}
text={description}
/>
);
}
const prefix = description.slice(0, authorIndex).trimEnd();
const suffix = description
.slice(authorIndex + authorLabel.length)
.trimStart();
return (
<span className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0">{prefix}</span>
<UserAvatar
avatarUrl={authorAvatarUrl ?? null}
className="h-4 w-4"
displayName={authorLabel}
fallbackDelayMs={0}
size="xs"
testId="workflow-trigger-author-avatar"
/>
<span className="min-w-0 truncate">
{authorLabel}{" "}
{suffix ? (
<TriggerDescriptionText
messageLoading={messageLoading}
text={suffix}
/>
) : null}
</span>
</span>
);
}
function TriggerDescriptionText({
messageLoading,
text,
}: {
messageLoading?: boolean;
text: string;
}) {
const loadingIndex = messageLoading
? text.indexOf(TRIGGER_MESSAGE_LOADING_LABEL)
: -1;
if (loadingIndex < 0) return text;
const prefix = text.slice(0, loadingIndex);
const suffix = text.slice(
loadingIndex + TRIGGER_MESSAGE_LOADING_LABEL.length,
);
return (
<>
{prefix}
<motion.span
animate={{ opacity: 1 }}
aria-label="Loading message"
className="inline-flex align-text-bottom"
data-testid="workflow-trigger-message-loading"
initial={{ opacity: 0 }}
role="status"
transition={{ delay: 0.5, duration: 0.15 }}
>
<LoaderCircle aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
</motion.span>
{suffix}
</>
);
}
export function WorkflowFormBuilder({
channels,
disabled,
@@ -789,15 +705,36 @@ export function WorkflowFormBuilder({
/>
{formState.steps.map((step, index) => {
const stepName = step.name?.trim();
const actionLabel = ACTION_LABELS[step.action];
const nodeTitle = stepName || actionLabel;
const channelLabel = step.channel
? channels.find(
(channel) => channel.id === step.channel,
)?.name
: undefined;
const nodeDescription = workflowStepDescription(step, {
channelLabel,
});
const showActionSubtitle =
nodeDescription !== actionLabel;
return (
<WorkflowNode
description={nodeTitle}
description={
step.action === "add_reaction" && step.emoji ? (
<StepReactionDescription
description={nodeDescription}
emoji={step.emoji}
emojiUrl={reactionEmojiUrl(
step.emoji,
customEmoji,
)}
/>
) : (
nodeDescription
)
}
disabled={disabled}
key={step.id}
label={`Step ${index + 1}: ${nodeTitle}`}
label={`Step ${index + 1}: ${nodeDescription}`}
number={index + 1}
onAddAfter={(action) => insertStep(index + 1, action)}
onClick={() => selectNode({ type: "step", index })}
@@ -807,7 +744,9 @@ export function WorkflowFormBuilder({
selectedNode.index === index
}
showTitle={false}
subtitle={stepName ? actionLabel : undefined}
subtitle={
showActionSubtitle ? actionLabel : undefined
}
terminal={index === formState.steps.length - 1}
title={`Step ${index + 1}`}
/>
@@ -0,0 +1,128 @@
import { LoaderCircle } from "lucide-react";
import { motion } from "motion/react";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TRIGGER_MESSAGE_LOADING_LABEL } from "./workflowTriggerDescription";
export function TriggerNodeDescription({
authorAvatarUrl,
authorLabel,
description,
messageLoading,
}: {
authorAvatarUrl?: string | null;
authorLabel?: string | null;
description: string;
messageLoading?: boolean;
}) {
const authorIndex = authorLabel ? description.lastIndexOf(authorLabel) : -1;
if (!authorLabel || authorIndex < 0) {
return (
<TriggerDescriptionText
messageLoading={messageLoading}
text={description}
/>
);
}
const prefix = description.slice(0, authorIndex).trimEnd();
const suffix = description
.slice(authorIndex + authorLabel.length)
.trimStart();
return (
<span className="flex min-w-0 items-center gap-1.5">
<span className="shrink-0">{prefix}</span>
<UserAvatar
avatarUrl={authorAvatarUrl ?? null}
className="h-4 w-4"
displayName={authorLabel}
fallbackDelayMs={0}
size="xs"
testId="workflow-trigger-author-avatar"
/>
<span className="min-w-0 truncate">
{authorLabel}{" "}
{suffix ? (
<TriggerDescriptionText
messageLoading={messageLoading}
text={suffix}
/>
) : null}
</span>
</span>
);
}
function TriggerDescriptionText({
messageLoading,
text,
}: {
messageLoading?: boolean;
text: string;
}) {
const loadingIndex = messageLoading
? text.indexOf(TRIGGER_MESSAGE_LOADING_LABEL)
: -1;
if (loadingIndex < 0) return text;
const prefix = text.slice(0, loadingIndex);
const suffix = text.slice(
loadingIndex + TRIGGER_MESSAGE_LOADING_LABEL.length,
);
return (
<>
{prefix}
<motion.span
animate={{ opacity: 1 }}
aria-label="Loading message"
className="inline-flex align-text-bottom"
data-testid="workflow-trigger-message-loading"
initial={{ opacity: 0 }}
role="status"
transition={{ delay: 0.5, duration: 0.15 }}
>
<LoaderCircle aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
</motion.span>
{suffix}
</>
);
}
export function StepReactionDescription({
description,
emoji,
emojiUrl,
}: {
description: string;
emoji: string;
emojiUrl?: string;
}) {
const emojiIndex = description.lastIndexOf(emoji);
if (emojiIndex < 0) return description;
const prefix = description.slice(0, emojiIndex);
const suffix = description.slice(emojiIndex + emoji.length);
return (
<span className="flex min-w-0 items-center gap-1.5">
{prefix ? <span className="truncate">{prefix}</span> : null}
{emojiUrl ? (
<img
alt={emoji}
className="h-5 w-5 shrink-0 object-contain"
data-testid="workflow-step-reaction-emoji"
draggable={false}
src={rewriteRelayUrl(emojiUrl)}
/>
) : (
<span
className="shrink-0 text-base leading-none"
data-testid="workflow-step-reaction-emoji"
>
{emoji}
</span>
)}
{suffix ? <span className="truncate">{suffix}</span> : null}
</span>
);
}
@@ -88,12 +88,12 @@ export const TRIGGER_LABELS: Record<TriggerType, string> = {
export const ACTION_LABELS: Record<ActionType, string> = {
delay: "Delay",
send_message: "Send Message",
send_message: "Send message",
send_dm: "Send DM",
call_webhook: "Call Webhook",
request_approval: "Request Approval",
add_reaction: "Add Reaction",
set_channel_topic: "Set Channel Topic",
call_webhook: "Call webhook",
request_approval: "Request approval",
add_reaction: "Add reaction",
set_channel_topic: "Set channel topic",
};
function toHeaderRows(
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import { workflowStepDescription } from "./workflowStepDescription.ts";
test("describes configured workflow steps on the canvas", () => {
assert.equal(
workflowStepDescription({
id: "message",
action: "send_message",
name: "say hello",
text: "hey yourself",
}),
"say hello · “hey yourself”",
);
assert.equal(
workflowStepDescription(
{
id: "channel-message",
action: "send_message",
text: "deploying now",
channel: "channel-id",
},
{ channelLabel: "deployments" },
),
"“deploying now” in #deployments",
);
assert.equal(
workflowStepDescription({
id: "delay",
action: "delay",
duration: "5m",
}),
"5m",
);
assert.equal(
workflowStepDescription({
id: "webhook",
action: "call_webhook",
method: "PATCH",
url: "https://example.com/deploy",
}),
"PATCH https://example.com/deploy",
);
assert.equal(
workflowStepDescription({
id: "dm",
action: "send_dm",
text: "Build finished",
to: "team-on-call",
}),
"“Build finished” to team-on-call",
);
assert.equal(
workflowStepDescription({
id: "approval",
action: "request_approval",
message: "Ship the release?",
from: "release-managers",
}),
"“Ship the release?” from release-managers",
);
assert.equal(
workflowStepDescription({
id: "reaction",
action: "add_reaction",
emoji: ":buzz:",
}),
":buzz:",
);
assert.equal(
workflowStepDescription({
id: "topic",
action: "set_channel_topic",
topic: "Shipping this week",
}),
"“Shipping this week”",
);
});
test("falls back to the action label until a step is configured", () => {
assert.equal(
workflowStepDescription({ id: "message", action: "send_message" }),
"Send message",
);
});
@@ -0,0 +1,73 @@
import { truncatePubkey } from "@/shared/lib/pubkey";
import { ACTION_LABELS } from "./workflowFormTypes";
import type { StepFormState } from "./workflowFormTypes";
const MAX_DETAIL_LENGTH = 42;
function compact(value: string): string {
const normalized = value.trim().replaceAll(/\s+/g, " ");
return normalized.length > MAX_DETAIL_LENGTH
? `${normalized.slice(0, MAX_DETAIL_LENGTH - 3)}...`
: normalized;
}
function quoted(value: string | undefined): string | null {
const normalized = value ? compact(value) : "";
return normalized ? `“${normalized}”` : null;
}
function destination(value: string | undefined): string | null {
const normalized = value?.trim();
if (!normalized) return null;
return /^[0-9a-f]{64}$/i.test(normalized)
? truncatePubkey(normalized)
: compact(normalized);
}
function configuredStepDetail(
step: StepFormState,
channelLabel?: string,
): string | null {
switch (step.action) {
case "delay":
return step.duration?.trim() || null;
case "send_message": {
const text = quoted(step.text);
const channel = channelLabel ? `#${channelLabel}` : null;
if (text && channel) return `${text} in ${channel}`;
return text ?? channel;
}
case "send_dm": {
const text = quoted(step.text);
const recipient = destination(step.to);
if (text && recipient) return `${text} to ${recipient}`;
return text ?? recipient;
}
case "call_webhook": {
const url = step.url?.trim();
if (!url) return null;
return `${step.method?.trim() || "POST"} ${compact(url)}`;
}
case "request_approval": {
const message = quoted(step.message);
const approver = destination(step.from);
if (message && approver) return `${message} from ${approver}`;
return message ?? approver;
}
case "add_reaction":
return step.emoji?.trim() || null;
case "set_channel_topic":
return quoted(step.topic);
}
}
/** Build the concise action summary rendered on a workflow step node. */
export function workflowStepDescription(
step: StepFormState,
options: { channelLabel?: string } = {},
): string {
const name = step.name?.trim();
const detail = configuredStepDetail(step, options.channelLabel);
if (name && detail) return `${name} · ${detail}`;
return name || detail || ACTION_LABELS[step.action];
}
+32 -13
View File
@@ -57,19 +57,19 @@ async function expectUnsupportedActionWarnings(
) {
await expect(menu.getByRole("menuitem")).toHaveText([
"Delay",
"Send Message",
"Call Webhook",
"Send message",
"Call webhook",
"Send DM",
"Request Approval",
"Add Reaction",
"Set Channel Topic",
"Request approval",
"Add reaction",
"Set channel topic",
]);
for (const label of [
"Send DM",
"Request Approval",
"Add Reaction",
"Set Channel Topic",
"Request approval",
"Add reaction",
"Set channel topic",
]) {
await expect(
menu
@@ -78,7 +78,7 @@ async function expectUnsupportedActionWarnings(
).toBeVisible();
}
for (const label of ["Delay", "Send Message", "Call Webhook"]) {
for (const label of ["Delay", "Send message", "Call webhook"]) {
await expect(
menu
.getByRole("menuitem", { name: label, exact: true })
@@ -344,7 +344,7 @@ test("keeps a step condition separate from its action configuration", async ({
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await page.getByRole("menuitem", { name: "Send message" }).click();
const inspector = dialog.getByTestId("workflow-node-inspector");
await expect(inspector.getByLabel("Message text")).toBeVisible();
@@ -518,7 +518,7 @@ test("chooses an add-reaction step emoji with the app emoji picker", async ({
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Add step" }).click();
await expectUnsupportedActionWarnings(page.getByRole("menu"));
await page.getByRole("menuitem", { name: "Add Reaction" }).click();
await page.getByRole("menuitem", { name: "Add reaction" }).click();
const inspector = dialog.getByTestId("workflow-node-inspector");
await expect(inspector).toContainText(
@@ -538,6 +538,13 @@ test("chooses an add-reaction step emoji with the app emoji picker", async ({
await picker.getByRole("button", { name: ":buzz:" }).first().click();
await expect(stepEmoji).toContainText(":buzz:");
const reactionStepNode = dialog.getByRole("button", {
name: "Step 1: :buzz:",
});
await expect(reactionStepNode).toContainText("Add reaction");
await expect(
reactionStepNode.getByTestId("workflow-step-reaction-emoji"),
).toHaveAttribute("alt", ":buzz:");
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/action: add_reaction[\s\S]*emoji: ":buzz:"/,
@@ -713,7 +720,7 @@ test("omits destination controls for a channel workflow", async ({ page }) => {
await dialog.getByLabel("Trigger event").click();
await page.getByRole("menuitem", { name: "Webhook" }).click();
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await page.getByRole("menuitem", { name: "Send message" }).click();
await expect(
dialog.getByRole("combobox", { name: "Channel override (optional)" }),
@@ -747,7 +754,7 @@ test("opens node configuration in a contextual inspector", async ({ page }) => {
await expect(initialIngress.getByLabel("Add step")).toBeVisible();
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Send Message" }).click();
await page.getByRole("menuitem", { name: "Send message" }).click();
const triggerNode = dialog.getByRole("button", { name: /^Trigger:/ });
const stepNode = dialog.getByRole("button", { name: /^Step 1:/ });
await expect(inspector.getByLabel("Step ID")).toHaveValue("step_1");
@@ -756,6 +763,18 @@ test("opens node configuration in a contextual inspector", async ({ page }) => {
"send_message",
);
await expect(inspector.getByLabel("Message text")).toBeVisible();
await inspector.getByLabel("Message text").fill("hey yourself");
await expect(stepNode).toHaveAttribute(
"aria-label",
"Step 1: “hey yourself”",
);
await expect(stepNode).toContainText("Send message");
await expect(stepNode).toContainText("“hey yourself”");
await inspector.getByLabel("Name (optional)").fill("say hello");
await expect(stepNode).toHaveAttribute(
"aria-label",
"Step 1: say hello · “hey yourself”",
);
await expect(
inspector.getByRole("heading", { name: "Step condition" }),
).toBeVisible();