Polish workflow trigger filters

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-14 09:22:22 -07:00
parent dfdde30d87
commit 2dcb090b61
8 changed files with 406 additions and 166 deletions
@@ -145,7 +145,8 @@ export function AuthorGridPicker({
);
function selectAuthor(pubkey: string) {
onChange(pubkey.toLowerCase());
const normalizedPubkey = pubkey.toLowerCase();
onChange(normalizedPubkey === normalizedValue ? "" : normalizedPubkey);
}
function loadNextPage() {
@@ -46,6 +46,7 @@ import {
getWorkflowTriggerSummary,
getWorkflowTriggerType,
} from "./workflowDefinition";
import { TriggerDescriptionText } from "./WorkflowNodeDescriptions";
import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation";
type WorkflowCardProps = {
@@ -182,6 +183,28 @@ function ReactionLabelText({
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,
@@ -348,14 +371,21 @@ export function WorkflowCard({
{triggerSummary ? (
<p className="mt-4 line-clamp-1 text-xs font-semibold text-white/70">
<ReactionLabelText
<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">
<ReactionLabelText reactions={cardReactions} text={cardLabel} />
<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">
@@ -1,8 +1,11 @@
import { ChevronRight } from "lucide-react";
import * as React from "react";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import type { Channel, UserProfileSummary } from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { Input } from "@/shared/ui/input";
import { AuthorGridPicker } from "./AuthorGridPicker";
import { MessageIdPicker } from "./MessageIdPicker";
@@ -13,7 +16,6 @@ import {
conditionFieldsForTrigger,
conditionOperatorsForField,
conditionOperatorNeedsValue,
CUSTOM_CONDITION_FIELD,
defaultConditionOperatorForField,
normalizeWebhookField,
parseConditionExpressions,
@@ -53,6 +55,10 @@ type ConditionEditorState = {
editors: ParsedConditionExpression[];
};
type ActiveConditionEditor =
| { kind: "field"; draft: ParsedConditionExpression }
| { kind: "custom"; draft: string };
function normalizedEditor(
editor: ParsedConditionExpression,
): ParsedConditionExpression {
@@ -105,13 +111,87 @@ function valuePlaceholder(field: string): string {
}
}
function ConditionEditorControls({
function compactValue(value: string): string {
const trimmed = value.trim();
if (trimmed.length <= 18) return trimmed;
return `${trimmed.slice(0, 10)}…${trimmed.slice(-5)}`;
}
function editorSummary(editor: ParsedConditionExpression): string {
const operator = operatorLabel(
editor.field,
editor.operator,
conditionOperatorsForField(editor.field).length === 2,
);
if (!conditionOperatorNeedsValue(editor.operator)) return operator;
const value = compactValue(editor.value);
if (!value) return "Off";
return editor.field === "trigger_text"
? `${operator} “${value}”`
: `${operator} ${value}`;
}
function authorSummaryLabel(
pubkey: string,
profile?: UserProfileSummary | null,
): string {
return (
profile?.displayName?.trim() ||
profile?.name?.trim() ||
profile?.nip05Handle?.trim() ||
truncatePubkey(pubkey)
);
}
function AuthorConditionSummary({
editor,
profile,
}: {
editor: ParsedConditionExpression;
profile?: UserProfileSummary | null;
}) {
const label = authorSummaryLabel(editor.value, profile);
const isExcluded = editor.operator === "not_equals";
return (
<span className="flex shrink-0 items-center">
<span className="relative shrink-0">
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-6 w-6"
iconClassName="h-4 w-4"
label={label}
/>
{isExcluded ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<span className="absolute inset-0 [clip-path:circle(50%_at_50%_50%)]">
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<span className="block h-1 w-9 translate-y-0.5 -rotate-45 rounded-full bg-background/90" />
</span>
</span>
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<span className="block h-0.5 w-8 -rotate-45 rounded-full bg-muted-foreground" />
</span>
</span>
) : null}
</span>
<span className="sr-only">
{isExcluded ? "Excluded author: " : "Selected author: "}
{label}
</span>
</span>
);
}
function ConditionEditorFields({
channelId,
disabled,
editor,
idPrefix,
knownAuthorPubkeys,
label,
onChange,
}: {
channelId?: string | null;
@@ -119,7 +199,6 @@ function ConditionEditorControls({
editor: ParsedConditionExpression;
idPrefix: string;
knownAuthorPubkeys: string[];
label: string;
onChange: (editor: ParsedConditionExpression) => void;
}) {
const needsValue = conditionOperatorNeedsValue(editor.operator);
@@ -132,13 +211,10 @@ function ConditionEditorControls({
const controlIdPrefix = `${idPrefix}-${editor.field}`;
return (
<fieldset
className="space-y-3 rounded-lg border border-border/60 bg-background/20 p-3"
<div
className="space-y-3"
data-testid={`workflow-condition-editor-${editor.field}`}
>
<legend className="px-1 text-xs font-semibold text-foreground">
{label}
</legend>
<div className="space-y-1.5">
<FieldLabel htmlFor={`${controlIdPrefix}-operator`}>Match</FieldLabel>
<FormSelect
@@ -241,7 +317,7 @@ function ConditionEditorControls({
)}
</div>
) : null}
</fieldset>
</div>
);
}
@@ -250,7 +326,6 @@ export function WorkflowConditionBuilder({
channels,
disabled,
idPrefix,
matchAllLabel = "All events",
onChange,
triggerType,
value,
@@ -301,12 +376,27 @@ export function WorkflowConditionBuilder({
const [editorState, setEditorState] = React.useState(() =>
initialEditorState(value, triggerType),
);
const [activeEditor, setActiveEditor] =
React.useState<ActiveConditionEditor | null>(null);
const previousTriggerType = React.useRef(triggerType);
const selectedAuthorPubkey = editorState.custom
? ""
: (editorState.editors
.find((editor) => editor.field === "trigger_author")
?.value.trim()
.toLowerCase() ?? "");
const selectedAuthorProfileQuery = useUsersBatchQuery(
selectedAuthorPubkey ? [selectedAuthorPubkey] : [],
);
const selectedAuthorProfile = selectedAuthorPubkey
? selectedAuthorProfileQuery.data?.profiles[selectedAuthorPubkey]
: undefined;
React.useEffect(() => {
if (previousTriggerType.current === triggerType) return;
previousTriggerType.current = triggerType;
setEditorState(initialEditorState(value, triggerType));
setActiveEditor(null);
}, [triggerType, value]);
const emitEditors = (editors: ParsedConditionExpression[]) => {
@@ -314,136 +404,185 @@ export function WorkflowConditionBuilder({
onChange(buildConditionExpressions(editors));
};
const updateEditor = (next: ParsedConditionExpression) => {
emitEditors(
editorState.editors.map((editor) =>
editor.field === next.field ? next : editor,
),
);
};
const evalexprFields = fields
.map((field) =>
field.value === "webhook_field" ? "trigger_<JSON field>" : field.value,
)
.join(", ");
const fieldOptions = [
{ label: matchAllLabel, value: "" },
...fields,
{ label: "Custom", value: CUSTOM_CONDITION_FIELD },
];
const selectedFields = new Set(
editorState.editors.map((editor) => editor.field),
);
const openFieldEditor = (field: (typeof fields)[number]) => {
if (
activeEditor?.kind === "field" &&
activeEditor.draft.field === field.value
) {
setActiveEditor(null);
return;
}
const existing = editorState.editors.find(
(editor) => editor.field === field.value,
);
setActiveEditor({
kind: "field",
draft: existing ?? {
field: field.value,
operator: defaultConditionOperatorForField(field.value),
value: "",
webhookField: "",
},
});
};
const updateFieldEditor = (draft: ParsedConditionExpression) => {
const isComplete =
(draft.field !== "webhook_field" ||
normalizeWebhookField(draft.webhookField) !== null) &&
(!conditionOperatorNeedsValue(draft.operator) ||
draft.value.trim().length > 0);
setActiveEditor({ kind: "field", draft });
if (!isComplete) {
emitEditors(
editorState.editors.filter((editor) => editor.field !== draft.field),
);
return;
}
emitEditors(
editorState.custom
? [draft]
: [
...editorState.editors.filter(
(editor) => editor.field !== draft.field,
),
draft,
],
);
};
const updateCustomEditor = (draft: string) => {
const trimmed = draft.trim();
setActiveEditor({
kind: "custom",
draft,
});
if (trimmed) {
setEditorState({ custom: true, editors: [] });
onChange(trimmed);
} else {
setEditorState({ custom: false, editors: [] });
onChange("");
}
};
return (
<div className="space-y-3">
<fieldset aria-label="Condition">
<legend className="sr-only">Condition</legend>
<div className="grid grid-cols-2 gap-2.5">
{fieldOptions.map((field) => {
const isMatchAll = field.value === "";
const isCustom = field.value === CUSTOM_CONDITION_FIELD;
const isSelected = isMatchAll
? !editorState.custom && editorState.editors.length === 0
: isCustom
? editorState.custom
: selectedFields.has(field.value);
return (
<div
className={cn(
"relative",
(isMatchAll || isCustom) && "col-span-2",
)}
key={field.value}
>
<button
aria-pressed={isSelected}
className={cn(
"flex min-h-12 w-full cursor-pointer items-center justify-center rounded-lg border px-3 py-2 text-center text-sm font-medium",
"outline-2 outline-offset-2 outline-transparent transition-[background-color,border-color,color,outline-color]",
"focus-visible:ring-2 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
isSelected
? "border-border/0 bg-transparent text-foreground outline-foreground/45"
: "border-border/70 bg-background/35 text-muted-foreground hover:border-border hover:bg-muted/55 hover:text-foreground hover:outline-muted-foreground/20",
)}
disabled={disabled}
onClick={() => {
if (isMatchAll) {
setEditorState({ custom: false, editors: [] });
onChange("");
return;
}
if (isCustom) {
setEditorState({ custom: true, editors: [] });
return;
}
if (isSelected) {
emitEditors(
editorState.editors.filter(
(editor) => editor.field !== field.value,
),
);
} else {
emitEditors([
...editorState.editors,
{
field: field.value,
operator: defaultConditionOperatorForField(
field.value,
),
value: "",
webhookField: "",
},
]);
}
}}
type="button"
>
{field.label}
</button>
</div>
<div className="divide-y divide-border/50">
{fields.map((field) => {
const editor = editorState.custom
? undefined
: editorState.editors.find(
(candidate) => candidate.field === field.value,
);
})}
</div>
</fieldset>
const isExpanded =
activeEditor?.kind === "field" &&
activeEditor.draft.field === field.value;
return (
<div key={field.value}>
<button
aria-expanded={isExpanded}
className="flex min-h-12 w-full items-center gap-3 py-3 text-left transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={() => openFieldEditor(field)}
type="button"
>
<span className="min-w-0 flex-1 truncate text-base font-medium">
{field.label}
</span>
{editor?.field === "trigger_author" ? (
<AuthorConditionSummary
editor={editor}
profile={selectedAuthorProfile}
/>
) : (
<span className="max-w-40 truncate text-sm text-muted-foreground">
{editor
? editorSummary(editor)
: field.value === "trigger_text"
? "Any"
: "Off"}
</span>
)}
<ChevronRight
className={`h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-150 motion-reduce:transition-none ${
isExpanded ? "rotate-90" : "rotate-0"
}`}
/>
</button>
{editorState.custom ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${idPrefix}-custom-expression`}>
Custom expression
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${idPrefix}-custom-expression`}
onChange={(event) => onChange(event.target.value)}
placeholder='e.g. str_contains(trigger_text, "deploy")'
value={value}
{isExpanded ? (
<div className="animate-in space-y-4 pb-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none">
<ConditionEditorFields
channelId={channelId}
disabled={disabled}
editor={activeEditor.draft}
idPrefix={idPrefix}
knownAuthorPubkeys={knownAuthorPubkeys}
onChange={updateFieldEditor}
/>
</div>
) : null}
</div>
);
})}
<div>
<button
aria-expanded={activeEditor?.kind === "custom"}
className="flex min-h-12 w-full items-center gap-3 py-3 text-left transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={() =>
setActiveEditor((current) =>
current?.kind === "custom"
? null
: {
kind: "custom",
draft: editorState.custom ? value : "",
},
)
}
type="button"
>
<span className="min-w-0 flex-1 truncate text-base font-medium">
Custom
</span>
<span className="max-w-40 truncate text-sm text-muted-foreground">
{editorState.custom ? compactValue(value) : "Off"}
</span>
<ChevronRight
className={`h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-150 motion-reduce:transition-none ${
activeEditor?.kind === "custom" ? "rotate-90" : "rotate-0"
}`}
/>
<p className="text-xs text-muted-foreground">
Use an evalexpr expression with <code>{evalexprFields}</code>.
</p>
</div>
) : (
editorState.editors.map((editor) => (
<ConditionEditorControls
channelId={channelId}
disabled={disabled}
editor={editor}
idPrefix={idPrefix}
key={editor.field}
knownAuthorPubkeys={knownAuthorPubkeys}
label={
fields.find((field) => field.value === editor.field)?.label ??
editor.field
}
onChange={updateEditor}
/>
))
)}
</button>
{activeEditor?.kind === "custom" ? (
<div className="animate-in space-y-4 pb-4 pt-1 fade-in slide-in-from-top-1 duration-150 motion-reduce:animate-none">
<div className="space-y-2">
<FieldLabel htmlFor={`${idPrefix}-custom-expression`}>
Expression
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${idPrefix}-custom-expression`}
onChange={(event) => updateCustomEditor(event.target.value)}
placeholder='e.g. str_contains(trigger_text, "deploy")'
value={activeEditor.draft}
/>
<p className="text-xs text-muted-foreground">
Use an evalexpr expression with <code>{evalexprFields}</code>.
</p>
</div>
</div>
) : null}
</div>
</div>
);
}
@@ -567,6 +567,7 @@ export function WorkflowFormBuilder({
<TriggerNodeDescription
authorAvatarUrl={triggerPresentation.authorAvatarUrl}
authorLabel={triggerPresentation.authorLabel}
authorLoading={triggerPresentation.authorLoading}
description={triggerDescription}
messageLoading={triggerPresentation.messageLoading}
/>
@@ -3,16 +3,21 @@ import { motion } from "motion/react";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { TRIGGER_MESSAGE_LOADING_LABEL } from "./workflowTriggerDescription";
import {
TRIGGER_AUTHOR_LOADING_LABEL,
TRIGGER_MESSAGE_LOADING_LABEL,
} from "./workflowTriggerDescription";
export function TriggerNodeDescription({
authorAvatarUrl,
authorLabel,
authorLoading,
description,
messageLoading,
}: {
authorAvatarUrl?: string | null;
authorLabel?: string | null;
authorLoading?: boolean;
description: string;
messageLoading?: boolean;
}) {
@@ -20,6 +25,7 @@ export function TriggerNodeDescription({
if (!authorLabel || authorIndex < 0) {
return (
<TriggerDescriptionText
authorLoading={authorLoading}
messageLoading={messageLoading}
text={description}
/>
@@ -45,6 +51,7 @@ export function TriggerNodeDescription({
{authorLabel}{" "}
{suffix ? (
<TriggerDescriptionText
authorLoading={authorLoading}
messageLoading={messageLoading}
text={suffix}
/>
@@ -54,39 +61,65 @@ export function TriggerNodeDescription({
);
}
function TriggerDescriptionText({
export function TriggerDescriptionText({
authorLoading,
messageLoading,
text,
}: {
authorLoading?: boolean;
messageLoading?: boolean;
text: string;
}) {
const loadingIndex = messageLoading
? text.indexOf(TRIGGER_MESSAGE_LOADING_LABEL)
: -1;
if (loadingIndex < 0) return text;
const references = [
authorLoading
? {
ariaLabel: "Loading author",
delay: 0,
testId: "workflow-trigger-author-loading",
token: TRIGGER_AUTHOR_LOADING_LABEL,
}
: null,
messageLoading
? {
ariaLabel: "Loading message",
delay: 0.5,
testId: "workflow-trigger-message-loading",
token: TRIGGER_MESSAGE_LOADING_LABEL,
}
: null,
].filter((reference) => reference !== null);
if (references.length === 0) return text;
const prefix = text.slice(0, loadingIndex);
const suffix = text.slice(
loadingIndex + TRIGGER_MESSAGE_LOADING_LABEL.length,
);
return (
<>
{prefix}
const parts: React.ReactNode[] = [];
let cursor = 0;
while (cursor < text.length) {
const next = references
.map((reference) => ({
index: text.indexOf(reference.token, cursor),
reference,
}))
.filter(({ index }) => index >= 0)
.sort((left, right) => left.index - right.index)[0];
if (!next) break;
if (next.index > cursor) parts.push(text.slice(cursor, next.index));
parts.push(
<motion.span
animate={{ opacity: 1 }}
aria-label="Loading message"
aria-label={next.reference.ariaLabel}
className="inline-flex align-text-bottom"
data-testid="workflow-trigger-message-loading"
data-testid={next.reference.testId}
initial={{ opacity: 0 }}
key={`${next.reference.token}-${next.index}`}
role="status"
transition={{ delay: 0.5, duration: 0.15 }}
transition={{ delay: next.reference.delay, duration: 0.15 }}
>
<LoaderCircle aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
</motion.span>
{suffix}
</>
);
</motion.span>,
);
cursor = next.index + next.reference.token.length;
}
if (cursor < text.length) parts.push(text.slice(cursor));
return parts.length > 0 ? parts : text;
}
export function StepReactionDescription({
@@ -13,6 +13,7 @@ const FULL_HEX_ID = /^[0-9a-f]{64}$/i;
export type WorkflowTriggerPresentation = {
authorAvatarUrl?: string | null;
authorLabel?: string;
authorLoading?: boolean;
description: string;
emoji?: string;
messageLoading?: boolean;
@@ -48,13 +49,17 @@ export function useWorkflowTriggerPresentation({
const authorProfile = authorPubkey
? profilesQuery.data?.profiles[authorPubkey.toLowerCase()]
: undefined;
const authorLabel = authorPubkey
? resolveUserLabel({
currentPubkey: identityQuery.data?.pubkey,
profiles: profilesQuery.data?.profiles,
pubkey: authorPubkey,
})
: undefined;
const authorLoading = Boolean(
authorPubkey && !authorProfile && profilesQuery.isFetching,
);
const authorLabel =
authorPubkey && !authorLoading
? resolveUserLabel({
currentPubkey: identityQuery.data?.pubkey,
profiles: profilesQuery.data?.profiles,
pubkey: authorPubkey,
})
: undefined;
const messageCondition = conditions?.find(
(condition) => condition.field === "trigger_message_id",
);
@@ -84,7 +89,9 @@ export function useWorkflowTriggerPresentation({
return {
authorAvatarUrl: authorProfile?.avatarUrl,
authorLabel,
authorLoading,
description: workflowTriggerDescription(trigger, {
authorLoading,
authorLabel,
messageLabel: message?.content.trim() || undefined,
messageLoading,
@@ -15,6 +15,17 @@ test("describes selected trigger conditions on the workflow canvas", () => {
"Message posted by Carl",
);
assert.equal(
workflowTriggerDescription(
{
on: "message_posted",
filter: `trigger_author == "${"a".repeat(64)}"`,
},
{ authorLoading: true },
),
"Message posted by loading author",
);
assert.equal(
workflowTriggerDescription({
on: "message_posted",
@@ -11,6 +11,16 @@ const EVENT_PHRASES = {
} as const;
export const TRIGGER_MESSAGE_LOADING_LABEL = "loading message";
export const TRIGGER_AUTHOR_LOADING_LABEL = "loading author";
function authorReference(
condition: ParsedConditionExpression,
authorLabel?: string,
authorLoading?: boolean,
): string {
if (authorLoading) return TRIGGER_AUTHOR_LOADING_LABEL;
return authorLabel ?? truncatePubkey(condition.value);
}
function quotedValue(value: string): string {
const normalized = value.trim().replaceAll(/\s+/g, " ");
@@ -60,6 +70,7 @@ function textConditionDescription(
export function workflowTriggerDescription(
trigger: TriggerConfig,
options: {
authorLoading?: boolean;
authorLabel?: string;
messageLabel?: string;
messageLoading?: boolean;
@@ -104,8 +115,11 @@ export function workflowTriggerDescription(
: eventPhrase;
}
if (authorCondition) {
const author =
options.authorLabel ?? truncatePubkey(authorCondition.value);
const author = authorReference(
authorCondition,
options.authorLabel,
options.authorLoading,
);
const attribution =
authorCondition.operator === "not_equals"
? ` by anyone except ${author}`
@@ -133,7 +147,11 @@ export function workflowTriggerDescription(
}
if (condition.field === "trigger_author") {
const author = options.authorLabel ?? truncatePubkey(condition.value);
const author = authorReference(
condition,
options.authorLabel,
options.authorLoading,
);
return condition.operator === "not_equals"
? `${eventPhrase} by anyone except ${author}`
: `${eventPhrase} by ${author}`;