mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish workflow index labels
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -20,8 +20,12 @@ import {
|
||||
} 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,
|
||||
@@ -34,9 +38,13 @@ import {
|
||||
getWorkflowDescription,
|
||||
getWorkflowDisplayStatus,
|
||||
getWorkflowPrimaryAction,
|
||||
getWorkflowPrimaryActionChannel,
|
||||
getWorkflowPrimaryActionEmoji,
|
||||
getWorkflowTriggerConfig,
|
||||
getWorkflowTriggerSummary,
|
||||
getWorkflowTriggerType,
|
||||
} from "./workflowDefinition";
|
||||
import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation";
|
||||
|
||||
type WorkflowCardProps = {
|
||||
workflow: Workflow;
|
||||
@@ -89,6 +97,87 @@ function StatusBadge({ status }: { status: Workflow["status"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function WorkflowCard({
|
||||
workflow,
|
||||
channelName,
|
||||
@@ -98,14 +187,54 @@ export function WorkflowCard({
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: WorkflowCardProps) {
|
||||
const customEmoji = useCustomEmoji();
|
||||
const displayStatus = getWorkflowDisplayStatus(workflow);
|
||||
const description = getWorkflowDescription(workflow.definition);
|
||||
const triggerSummary = getWorkflowTriggerSummary(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 cardLabel = getWorkflowCardLabel(workflow.definition);
|
||||
const actionChannel = getWorkflowPrimaryActionChannel(workflow.definition);
|
||||
const actionReaction = getWorkflowPrimaryActionEmoji(workflow.definition);
|
||||
const actionChannelLabel =
|
||||
channelName && (!actionChannel || actionChannel === workflow.channelId)
|
||||
? channelName
|
||||
: undefined;
|
||||
const cardLabel = getWorkflowCardLabel(workflow.definition, {
|
||||
actionChannelLabel,
|
||||
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 (
|
||||
@@ -134,7 +263,14 @@ export function WorkflowCard({
|
||||
<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 ? (
|
||||
{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" />
|
||||
@@ -144,7 +280,16 @@ export function WorkflowCard({
|
||||
<>
|
||||
<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" />
|
||||
{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}
|
||||
@@ -190,11 +335,14 @@ export function WorkflowCard({
|
||||
|
||||
{triggerSummary ? (
|
||||
<p className="mt-4 line-clamp-1 text-xs font-semibold text-white/70">
|
||||
{triggerSummary}
|
||||
<ReactionLabelText
|
||||
reactions={cardReactions}
|
||||
text={triggerSummary}
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
<h3 className="mt-1 line-clamp-4 text-xl font-bold leading-tight tracking-tight">
|
||||
{cardLabel}
|
||||
<ReactionLabelText reactions={cardReactions} text={cardLabel} />
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-white/75">
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getWorkflowCardLabel } from "./workflowDefinition.ts";
|
||||
import {
|
||||
getWorkflowCardLabel,
|
||||
getWorkflowPrimaryActionEmoji,
|
||||
} from "./workflowDefinition.ts";
|
||||
|
||||
test("builds a plain-language workflow card label", () => {
|
||||
assert.equal(
|
||||
getWorkflowCardLabel({
|
||||
trigger: { on: "message_posted" },
|
||||
steps: [{ action: "send_message" }],
|
||||
steps: [{ action: "send_message", text: "Deploying now" }],
|
||||
}),
|
||||
"When a message is posted, send a channel message",
|
||||
"When a message is posted, send “Deploying now”",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
@@ -22,6 +25,90 @@ test("builds a plain-language workflow card label", () => {
|
||||
}),
|
||||
"When someone reacts with 🔥, wait 5m, then 1 more step",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
getWorkflowCardLabel(
|
||||
{
|
||||
trigger: {
|
||||
on: "reaction_added",
|
||||
filter: 'trigger_emoji == "🫠"',
|
||||
},
|
||||
steps: [{ action: "add_reaction", emoji: "👍" }],
|
||||
},
|
||||
{ triggerReaction: "🫠" },
|
||||
),
|
||||
"When someone reacts with 🫠, add a 👍 reaction",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
getWorkflowCardLabel(
|
||||
{
|
||||
trigger: { on: "message_posted" },
|
||||
steps: [{ action: "send_message", text: "Deploying now" }],
|
||||
},
|
||||
{ actionChannelLabel: "releases" },
|
||||
),
|
||||
"When a message is posted, send “Deploying now” in #releases",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
getWorkflowCardLabel(
|
||||
{
|
||||
trigger: {
|
||||
on: "message_posted",
|
||||
filter: `trigger_author == "${"a".repeat(64)}"`,
|
||||
},
|
||||
steps: [{ action: "send_message", text: "Deploying now" }],
|
||||
},
|
||||
{ triggerDescription: "Message posted by Carl" },
|
||||
),
|
||||
"When a message is posted by Carl, send “Deploying now”",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
getWorkflowCardLabel(
|
||||
{
|
||||
trigger: {
|
||||
on: "message_posted",
|
||||
filter: 'str_contains(trigger_text, "deploy")',
|
||||
},
|
||||
steps: [{ action: "call_webhook" }],
|
||||
},
|
||||
{ triggerDescription: "Message contains “deploy”" },
|
||||
),
|
||||
"When a message contains “deploy”, call a webhook",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
getWorkflowCardLabel(
|
||||
{
|
||||
trigger: {
|
||||
on: "message_posted",
|
||||
filter: `trigger_text == "FUCK" && trigger_author == "${"a".repeat(64)}"`,
|
||||
},
|
||||
steps: [{ action: "send_message", text: "{{trigger.text}} yourself" }],
|
||||
},
|
||||
{
|
||||
triggerDescription: "Message “FUCK” is posted by Carl",
|
||||
},
|
||||
),
|
||||
"When “FUCK” is posted by Carl, send “{{trigger.text}} yourself”",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the primary add-reaction emoji for rich card rendering", () => {
|
||||
assert.equal(
|
||||
getWorkflowPrimaryActionEmoji({
|
||||
steps: [{ action: "add_reaction", emoji: ":blob-wave:" }],
|
||||
}),
|
||||
":blob-wave:",
|
||||
);
|
||||
assert.equal(
|
||||
getWorkflowPrimaryActionEmoji({
|
||||
steps: [{ action: "send_message", emoji: ":blob-wave:" }],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("summarizes common and custom schedules", () => {
|
||||
|
||||
@@ -3,9 +3,18 @@ import {
|
||||
scheduleFormFromTrigger,
|
||||
SCHEDULE_FREQUENCY_LABELS,
|
||||
} from "./workflowSchedule";
|
||||
import { ACTION_LABELS, TRIGGER_LABELS } from "./workflowFormTypes";
|
||||
import type { ActionType } from "./workflowFormTypes";
|
||||
import type { TriggerType } from "./workflowFormTypes";
|
||||
import {
|
||||
ACTION_LABELS,
|
||||
TRIGGER_LABELS,
|
||||
TRIGGER_TYPES,
|
||||
} from "./workflowFormTypes";
|
||||
import type {
|
||||
ActionType,
|
||||
StepFormState,
|
||||
TriggerConfig,
|
||||
TriggerType,
|
||||
} from "./workflowFormTypes";
|
||||
import { workflowStepDescription } from "./workflowStepDescription";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -44,6 +53,41 @@ export function getWorkflowPrimaryAction(
|
||||
return nonEmptyString(getWorkflowSteps(definition)[0]?.action);
|
||||
}
|
||||
|
||||
export function getWorkflowPrimaryActionChannel(
|
||||
definition: Record<string, unknown>,
|
||||
): string | null {
|
||||
return nonEmptyString(getWorkflowSteps(definition)[0]?.channel);
|
||||
}
|
||||
|
||||
export function getWorkflowPrimaryActionEmoji(
|
||||
definition: Record<string, unknown>,
|
||||
): string | null {
|
||||
const step = getWorkflowSteps(definition)[0];
|
||||
return step?.action === "add_reaction" ? nonEmptyString(step.emoji) : null;
|
||||
}
|
||||
|
||||
export function getWorkflowTriggerConfig(
|
||||
definition: Record<string, unknown>,
|
||||
): TriggerConfig | null {
|
||||
const trigger = asRecord(definition.trigger);
|
||||
const triggerType = nonEmptyString(trigger?.on);
|
||||
if (
|
||||
!trigger ||
|
||||
!triggerType ||
|
||||
!TRIGGER_TYPES.includes(triggerType as TriggerType)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
on: triggerType as TriggerType,
|
||||
filter: nonEmptyString(trigger.filter) ?? undefined,
|
||||
emoji: nonEmptyString(trigger.emoji) ?? undefined,
|
||||
cron: nonEmptyString(trigger.cron) ?? undefined,
|
||||
interval: nonEmptyString(trigger.interval) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getScheduleCardClause(trigger: Record<string, unknown>): string {
|
||||
const schedule = scheduleFormFromTrigger({
|
||||
on: "schedule",
|
||||
@@ -69,7 +113,10 @@ function getScheduleCardClause(trigger: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getTriggerCardClause(definition: Record<string, unknown>): string {
|
||||
function getTriggerCardClause(
|
||||
definition: Record<string, unknown>,
|
||||
presentedReaction?: string,
|
||||
): string {
|
||||
const trigger = asRecord(definition.trigger);
|
||||
const triggerType = nonEmptyString(trigger?.on);
|
||||
if (!trigger || !triggerType) return "When this workflow starts";
|
||||
@@ -80,7 +127,7 @@ function getTriggerCardClause(definition: Record<string, unknown>): string {
|
||||
? "When a matching message is posted"
|
||||
: "When a message is posted";
|
||||
case "reaction_added": {
|
||||
const emoji = nonEmptyString(trigger.emoji);
|
||||
const emoji = nonEmptyString(presentedReaction ?? trigger.emoji);
|
||||
return emoji
|
||||
? `When someone reacts with ${emoji}`
|
||||
: "When someone adds a reaction";
|
||||
@@ -98,32 +145,187 @@ function getTriggerCardClause(definition: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getActionCardClause(step: Record<string, unknown>): string | null {
|
||||
function eventDescriptionCardClause(
|
||||
description: string,
|
||||
event: "message" | "diff",
|
||||
): string | null {
|
||||
const subject = event === "message" ? "Message" : "Diff";
|
||||
const eventPhrase = `${subject} posted`;
|
||||
if (!description.startsWith(eventPhrase)) return null;
|
||||
|
||||
const detail = description.slice(eventPhrase.length);
|
||||
if (!detail) return `When a ${event} is posted`;
|
||||
if (detail.startsWith(" is ") || detail.startsWith(" is not ")) {
|
||||
return `When a ${event}${detail}`;
|
||||
}
|
||||
if (
|
||||
detail.startsWith(" containing ") ||
|
||||
detail.startsWith(" without ") ||
|
||||
detail.startsWith(" starting with ") ||
|
||||
detail.startsWith(" ending with ") ||
|
||||
detail === " with text"
|
||||
) {
|
||||
return `When a ${event}${detail} is posted`;
|
||||
}
|
||||
return `When a ${event} is posted${detail}`;
|
||||
}
|
||||
|
||||
function textDescriptionCardClause(
|
||||
description: string,
|
||||
event: "message" | "diff",
|
||||
): string | null {
|
||||
const subject = event === "message" ? "Message" : "Diff";
|
||||
if (!description.startsWith(`${subject} `)) return null;
|
||||
return `When a ${event}${description.slice(subject.length)}`;
|
||||
}
|
||||
|
||||
function postedValueCardClause(description: string): string | null {
|
||||
const postedIndex = description.indexOf(" posted");
|
||||
if (postedIndex < 1) return null;
|
||||
|
||||
const value = description.slice(0, postedIndex);
|
||||
if (!value.startsWith("“") && !value.startsWith("Anything except ")) {
|
||||
return null;
|
||||
}
|
||||
const normalizedValue = value.startsWith("Anything")
|
||||
? `anything${value.slice("Anything".length)}`
|
||||
: value;
|
||||
return `When ${normalizedValue} is posted${description.slice(postedIndex + " posted".length)}`;
|
||||
}
|
||||
|
||||
function qualifiedPostedEventCardClause(
|
||||
description: string,
|
||||
event: "message" | "diff",
|
||||
): string | null {
|
||||
const subject = event === "message" ? "Message" : "Diff";
|
||||
const postedIndex = description.indexOf(" posted");
|
||||
if (postedIndex < 1) return null;
|
||||
|
||||
const suffix = description.slice(postedIndex + " posted".length);
|
||||
const otherTextPrefix = `${subject} with text other than `;
|
||||
if (description.startsWith(otherTextPrefix)) {
|
||||
const value = description.slice(otherTextPrefix.length, postedIndex);
|
||||
return `When a ${event} with text other than ${value} is posted${suffix}`;
|
||||
}
|
||||
|
||||
if (description.startsWith(`${subject} with text posted`)) {
|
||||
return `When a ${event} with text is posted${suffix}`;
|
||||
}
|
||||
if (description.startsWith(`${subject} without text posted`)) {
|
||||
return `When a ${event} without text is posted${suffix}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function exactValueEventCardClause(
|
||||
description: string,
|
||||
event: "message" | "diff",
|
||||
): string | null {
|
||||
const subject = event === "message" ? "Message" : "Diff";
|
||||
const prefix = `${subject} “`;
|
||||
const postedMarker = " is posted";
|
||||
if (!description.startsWith(prefix)) return null;
|
||||
|
||||
const postedIndex = description.indexOf(postedMarker);
|
||||
if (postedIndex < prefix.length) return null;
|
||||
const value = description.slice(subject.length + 1, postedIndex);
|
||||
const suffix = description.slice(postedIndex + postedMarker.length);
|
||||
return event === "message"
|
||||
? `When ${value} is posted${suffix}`
|
||||
: `When a diff ${value} is posted${suffix}`;
|
||||
}
|
||||
|
||||
function getPresentedTriggerCardClause(description?: string): string | null {
|
||||
if (!description) return null;
|
||||
|
||||
const eventClause =
|
||||
exactValueEventCardClause(description, "message") ??
|
||||
exactValueEventCardClause(description, "diff") ??
|
||||
qualifiedPostedEventCardClause(description, "message") ??
|
||||
qualifiedPostedEventCardClause(description, "diff") ??
|
||||
postedValueCardClause(description) ??
|
||||
eventDescriptionCardClause(description, "message") ??
|
||||
eventDescriptionCardClause(description, "diff") ??
|
||||
textDescriptionCardClause(description, "message") ??
|
||||
textDescriptionCardClause(description, "diff");
|
||||
if (eventClause) return eventClause;
|
||||
|
||||
if (description.startsWith("Reaction added")) {
|
||||
return `When a reaction is added${description.slice("Reaction added".length)}`;
|
||||
}
|
||||
if (
|
||||
description.startsWith("Any reaction ") &&
|
||||
description.endsWith(" added")
|
||||
) {
|
||||
return `When ${description.slice(0, -" added".length).toLocaleLowerCase()} is added`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getActionCardClause(
|
||||
step: Record<string, unknown>,
|
||||
channelLabel?: string,
|
||||
): string | null {
|
||||
const action = nonEmptyString(step.action);
|
||||
if (!action) return null;
|
||||
|
||||
const actionLabel = ACTION_LABELS[action as ActionType];
|
||||
const parsedStep: StepFormState | null = actionLabel
|
||||
? {
|
||||
action: action as ActionType,
|
||||
id: nonEmptyString(step.id) ?? "step_1",
|
||||
duration: nonEmptyString(step.duration) ?? undefined,
|
||||
emoji: nonEmptyString(step.emoji) ?? undefined,
|
||||
from: nonEmptyString(step.from) ?? undefined,
|
||||
message: nonEmptyString(step.message) ?? undefined,
|
||||
method: nonEmptyString(step.method) ?? undefined,
|
||||
name: nonEmptyString(step.name) ?? undefined,
|
||||
text: nonEmptyString(step.text) ?? undefined,
|
||||
to: nonEmptyString(step.to) ?? undefined,
|
||||
topic: nonEmptyString(step.topic) ?? undefined,
|
||||
url: nonEmptyString(step.url) ?? undefined,
|
||||
}
|
||||
: null;
|
||||
const detail = parsedStep
|
||||
? workflowStepDescription(parsedStep, {
|
||||
channelLabel,
|
||||
includeName: false,
|
||||
})
|
||||
: null;
|
||||
const configuredDetail = detail && detail !== actionLabel ? detail : null;
|
||||
|
||||
switch (action) {
|
||||
case "delay": {
|
||||
const duration = nonEmptyString(step.duration);
|
||||
return duration ? `wait ${duration}` : "wait for a moment";
|
||||
return configuredDetail
|
||||
? `wait ${configuredDetail}`
|
||||
: "wait for a moment";
|
||||
}
|
||||
case "send_message":
|
||||
return "send a channel message";
|
||||
if (!configuredDetail) return "send a channel message";
|
||||
return nonEmptyString(step.text)
|
||||
? `send ${configuredDetail}`
|
||||
: `send a message in ${configuredDetail}`;
|
||||
case "call_webhook":
|
||||
return "call a webhook";
|
||||
return configuredDetail ? `call ${configuredDetail}` : "call a webhook";
|
||||
case "send_dm":
|
||||
return "send a direct message";
|
||||
return configuredDetail
|
||||
? `send ${configuredDetail}`
|
||||
: "send a direct message";
|
||||
case "request_approval":
|
||||
return "request approval";
|
||||
return configuredDetail
|
||||
? `request approval: ${configuredDetail}`
|
||||
: "request approval";
|
||||
case "add_reaction": {
|
||||
const emoji = nonEmptyString(step.emoji);
|
||||
return emoji ? `add a ${emoji} reaction` : "add a reaction";
|
||||
return configuredDetail
|
||||
? `add a ${configuredDetail} reaction`
|
||||
: "add a reaction";
|
||||
}
|
||||
case "set_channel_topic":
|
||||
return "update the channel topic";
|
||||
return configuredDetail
|
||||
? `set the channel topic to ${configuredDetail}`
|
||||
: "update the channel topic";
|
||||
default: {
|
||||
const knownLabel = ACTION_LABELS[action as ActionType];
|
||||
return (knownLabel ?? humanizeIdentifier(action)).toLocaleLowerCase();
|
||||
return (actionLabel ?? humanizeIdentifier(action)).toLocaleLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,10 +333,19 @@ function getActionCardClause(step: Record<string, unknown>): string | null {
|
||||
/** Build a short plain-language label from the workflow's trigger and steps. */
|
||||
export function getWorkflowCardLabel(
|
||||
definition: Record<string, unknown>,
|
||||
options: {
|
||||
actionChannelLabel?: string;
|
||||
triggerDescription?: string;
|
||||
triggerReaction?: string;
|
||||
} = {},
|
||||
): string {
|
||||
const triggerClause = getTriggerCardClause(definition);
|
||||
const triggerClause =
|
||||
getPresentedTriggerCardClause(options.triggerDescription) ??
|
||||
getTriggerCardClause(definition, options.triggerReaction);
|
||||
const steps = getWorkflowSteps(definition);
|
||||
const firstAction = steps[0] ? getActionCardClause(steps[0]) : null;
|
||||
const firstAction = steps[0]
|
||||
? getActionCardClause(steps[0], options.actionChannelLabel)
|
||||
: null;
|
||||
if (!firstAction) return triggerClause;
|
||||
|
||||
const remainingStepCount = steps.length - 1;
|
||||
|
||||
@@ -84,3 +84,18 @@ test("falls back to the action label until a step is configured", () => {
|
||||
"Send message",
|
||||
);
|
||||
});
|
||||
|
||||
test("can omit a custom step name when composing an index label", () => {
|
||||
assert.equal(
|
||||
workflowStepDescription(
|
||||
{
|
||||
id: "message",
|
||||
action: "send_message",
|
||||
name: "say hello",
|
||||
text: "hey yourself",
|
||||
},
|
||||
{ includeName: false },
|
||||
),
|
||||
"“hey yourself”",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,9 +64,9 @@ function configuredStepDetail(
|
||||
/** Build the concise action summary rendered on a workflow step node. */
|
||||
export function workflowStepDescription(
|
||||
step: StepFormState,
|
||||
options: { channelLabel?: string } = {},
|
||||
options: { channelLabel?: string; includeName?: boolean } = {},
|
||||
): string {
|
||||
const name = step.name?.trim();
|
||||
const name = options.includeName === false ? undefined : step.name?.trim();
|
||||
const detail = configuredStepDetail(step, options.channelLabel);
|
||||
if (name && detail) return `${name} · ${detail}`;
|
||||
return name || detail || ACTION_LABELS[step.action];
|
||||
|
||||
@@ -20,7 +20,31 @@ test("describes selected trigger conditions on the workflow canvas", () => {
|
||||
on: "message_posted",
|
||||
filter: 'str_contains(trigger_text, "deploy")',
|
||||
}),
|
||||
"Message posted containing “deploy”",
|
||||
"Message contains “deploy”",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
workflowTriggerDescription({
|
||||
on: "message_posted",
|
||||
filter: '!str_contains(trigger_text, "deploy")',
|
||||
}),
|
||||
"Message doesn’t contain “deploy”",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
workflowTriggerDescription({
|
||||
on: "message_posted",
|
||||
filter: 'str_starts_with(trigger_text, "deploy")',
|
||||
}),
|
||||
"Message starts with “deploy”",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
workflowTriggerDescription({
|
||||
on: "message_posted",
|
||||
filter: 'str_ends_with(trigger_text, "done")',
|
||||
}),
|
||||
"Message ends with “done”",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
@@ -28,7 +52,7 @@ test("describes selected trigger conditions on the workflow canvas", () => {
|
||||
on: "message_posted",
|
||||
filter: 'trigger_text == "deploy"',
|
||||
}),
|
||||
"Message posted is “deploy”",
|
||||
"Message “deploy” is posted",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
@@ -36,7 +60,29 @@ test("describes selected trigger conditions on the workflow canvas", () => {
|
||||
on: "message_posted",
|
||||
filter: 'trigger_text != "deploy"',
|
||||
}),
|
||||
"Message posted is not “deploy”",
|
||||
"Message with text other than “deploy” posted",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
workflowTriggerDescription(
|
||||
{
|
||||
on: "message_posted",
|
||||
filter: `trigger_text == "FUCK" && trigger_author == "${"a".repeat(64)}"`,
|
||||
},
|
||||
{ authorLabel: "Carl" },
|
||||
),
|
||||
"Message “FUCK” is posted by Carl",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
workflowTriggerDescription(
|
||||
{
|
||||
on: "message_posted",
|
||||
filter: `str_contains(trigger_text, "deploy") && trigger_author == "${"a".repeat(64)}"`,
|
||||
},
|
||||
{ authorLabel: "Carl" },
|
||||
),
|
||||
"Message by Carl contains “deploy”",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
@@ -44,7 +90,7 @@ test("describes selected trigger conditions on the workflow canvas", () => {
|
||||
on: "message_posted",
|
||||
filter: "str_len(trigger_text) == 0",
|
||||
}),
|
||||
"Message posted without text",
|
||||
"Message without text posted",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
@@ -52,7 +98,7 @@ test("describes selected trigger conditions on the workflow canvas", () => {
|
||||
on: "message_posted",
|
||||
filter: "str_len(trigger_text) > 0",
|
||||
}),
|
||||
"Messaged posted with text",
|
||||
"Message with text posted",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
|
||||
@@ -34,26 +34,25 @@ function textConditionDescription(
|
||||
eventPhrase: string,
|
||||
condition: ParsedConditionExpression,
|
||||
): string {
|
||||
const subject = eventPhrase.replace(/ posted$/, "");
|
||||
const value = quotedValue(condition.value);
|
||||
switch (condition.operator) {
|
||||
case "contains":
|
||||
return `${eventPhrase} containing ${value}`;
|
||||
return `${subject} contains ${value}`;
|
||||
case "not_contains":
|
||||
return `${eventPhrase} without ${value}`;
|
||||
return `${subject} doesn’t contain ${value}`;
|
||||
case "starts_with":
|
||||
return `${eventPhrase} starting with ${value}`;
|
||||
return `${subject} starts with ${value}`;
|
||||
case "ends_with":
|
||||
return `${eventPhrase} ending with ${value}`;
|
||||
return `${subject} ends with ${value}`;
|
||||
case "equals":
|
||||
return `${eventPhrase} is ${value}`;
|
||||
return `${subject} ${value} is posted`;
|
||||
case "not_equals":
|
||||
return `${eventPhrase} is not ${value}`;
|
||||
return `${subject} with text other than ${value} posted`;
|
||||
case "is_not_empty":
|
||||
return eventPhrase === "Message posted"
|
||||
? "Messaged posted with text"
|
||||
: `${eventPhrase} containing text`;
|
||||
return `${subject} with text posted`;
|
||||
case "is_empty":
|
||||
return `${eventPhrase} without text`;
|
||||
return `${subject} without text posted`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +106,17 @@ export function workflowTriggerDescription(
|
||||
if (authorCondition) {
|
||||
const author =
|
||||
options.authorLabel ?? truncatePubkey(authorCondition.value);
|
||||
description +=
|
||||
const attribution =
|
||||
authorCondition.operator === "not_equals"
|
||||
? ` by anyone except ${author}`
|
||||
: ` by ${author}`;
|
||||
const subject = eventPhrase.replace(/ posted$/, "");
|
||||
description =
|
||||
textCondition &&
|
||||
description.startsWith(subject) &&
|
||||
!/ (?:is )?posted$/.test(description)
|
||||
? `${subject}${attribution}${description.slice(subject.length)}`
|
||||
: `${description}${attribution}`;
|
||||
}
|
||||
if (messageCondition) {
|
||||
const message = messageReference(
|
||||
|
||||
Reference in New Issue
Block a user