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