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