Refine workflow filter editing

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-14 09:51:14 -07:00
parent 4c2a83e90a
commit 705a0b566d
3 changed files with 232 additions and 137 deletions
@@ -1,4 +1,4 @@
import { Check, ChevronDown, Hash, Lock, Search } from "lucide-react";
import { Asterisk, Check, ChevronDown, Hash, Lock, Search } from "lucide-react";
import * as React from "react";
import type { Channel } from "@/shared/api/types";
@@ -21,6 +21,7 @@ type ChannelComboboxProps = {
id?: string;
isChannelDisabled?: (channel: Channel) => boolean;
onChange: (value: string) => void;
required?: boolean;
variant?: "header" | "field";
value: string;
};
@@ -34,6 +35,7 @@ export function ChannelCombobox({
id,
isChannelDisabled,
onChange,
required = false,
variant = "header",
value,
}: ChannelComboboxProps) {
@@ -107,6 +109,7 @@ export function ChannelCombobox({
<button
aria-label={ariaLabel}
aria-expanded={open}
aria-required={required}
className={cn(
"group flex w-full items-center rounded-lg px-3 py-2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
variant === "header" &&
@@ -126,7 +129,11 @@ export function ChannelCombobox({
variant === "header" && "justify-center",
)}
>
{selected ? <ChannelPrivacyIcon channel={selected} /> : null}
{selected ? (
<ChannelPrivacyIcon channel={selected} />
) : required && !value ? (
<Asterisk aria-hidden className="h-5 w-5 shrink-0 text-primary" />
) : null}
<span className="truncate">
{selected
? selected.name
@@ -7,10 +7,23 @@ import { useIdentityQuery } from "@/shared/api/hooks";
import type { Channel, UserProfileSummary } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { truncatePubkey } from "@/shared/lib/pubkey";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { AuthorGridPicker } from "./AuthorGridPicker";
import { MessageIdPicker } from "./MessageIdPicker";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { FieldLabel } from "./workflowFormPrimitives";
import {
buildConditionExpressions,
conditionFieldsForTrigger,
@@ -55,9 +68,12 @@ type ConditionEditorState = {
editors: ParsedConditionExpression[];
};
type ActiveConditionEditor =
| { kind: "field"; draft: ParsedConditionExpression }
| { kind: "custom"; draft: string };
type ConditionEditorMode = "basic" | "advanced";
type ActiveConditionEditor = {
kind: "field";
draft: ParsedConditionExpression;
};
function normalizedEditor(
editor: ParsedConditionExpression,
@@ -233,17 +249,6 @@ function ConditionEditorFields({
className="space-y-3"
data-testid={`workflow-condition-editor-${editor.field}`}
>
{needsValue && editor.field === "trigger_text" ? valueInput : null}
{needsValue && editor.field === "trigger_author" ? (
<AuthorGridPicker
disabled={disabled}
id={`${controlIdPrefix}-value`}
knownPubkeys={knownAuthorPubkeys}
onChange={(pubkey) => onChange({ ...editor, value: pubkey })}
value={editor.value}
/>
) : null}
<fieldset>
<legend className="sr-only">Match</legend>
<div className="grid grid-cols-2 gap-2.5">
@@ -287,6 +292,9 @@ function ConditionEditorFields({
{editor.field === "webhook_field" ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${controlIdPrefix}-webhook-field`}>
JSON field name
</FieldLabel>
<Input
aria-label="JSON field name"
autoCapitalize="off"
@@ -311,11 +319,22 @@ function ConditionEditorFields({
</div>
) : null}
{needsValue &&
editor.field !== "trigger_text" &&
editor.field !== "trigger_author" ? (
<div>
{editor.field === "trigger_emoji" ? (
{needsValue ? (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${controlIdPrefix}-value`}>
{editor.field === "webhook_field"
? "Value"
: valueLabel(editor.field)}
</FieldLabel>
{editor.field === "trigger_author" ? (
<AuthorGridPicker
disabled={disabled}
id={`${controlIdPrefix}-value`}
knownPubkeys={knownAuthorPubkeys}
onChange={(pubkey) => onChange({ ...editor, value: pubkey })}
value={editor.value}
/>
) : editor.field === "trigger_emoji" ? (
<WorkflowEmojiField
ariaLabel="Choose condition emoji"
clearAriaLabel="Clear condition emoji"
@@ -398,8 +417,15 @@ export function WorkflowConditionBuilder({
const [editorState, setEditorState] = React.useState(() =>
initialEditorState(value, triggerType),
);
const [editorMode, setEditorMode] = React.useState<ConditionEditorMode>(() =>
initialEditorState(value, triggerType).custom ? "advanced" : "basic",
);
const [advancedDraft, setAdvancedDraft] = React.useState(value);
const [activeEditor, setActiveEditor] =
React.useState<ActiveConditionEditor | null>(null);
const [pendingBasicField, setPendingBasicField] = React.useState<
(typeof fields)[number] | null
>(null);
const previousTriggerType = React.useRef(triggerType);
const selectedAuthorPubkey = editorState.custom
? ""
@@ -417,13 +443,19 @@ export function WorkflowConditionBuilder({
React.useEffect(() => {
if (previousTriggerType.current === triggerType) return;
previousTriggerType.current = triggerType;
setEditorState(initialEditorState(value, triggerType));
const nextEditorState = initialEditorState(value, triggerType);
setEditorState(nextEditorState);
setEditorMode(nextEditorState.custom ? "advanced" : "basic");
setAdvancedDraft(value);
setActiveEditor(null);
setPendingBasicField(null);
}, [triggerType, value]);
const emitEditors = (editors: ParsedConditionExpression[]) => {
const expression = buildConditionExpressions(editors);
setEditorState({ custom: false, editors });
onChange(buildConditionExpressions(editors));
setAdvancedDraft(expression);
onChange(expression);
};
const evalexprFields = fields
@@ -431,11 +463,8 @@ export function WorkflowConditionBuilder({
field.value === "webhook_field" ? "trigger_<JSON field>" : field.value,
)
.join(", ");
const openFieldEditor = (field: (typeof fields)[number]) => {
if (
activeEditor?.kind === "field" &&
activeEditor.draft.field === field.value
) {
const showFieldEditor = (field: (typeof fields)[number]) => {
if (activeEditor?.draft.field === field.value) {
setActiveEditor(null);
return;
}
@@ -453,6 +482,33 @@ export function WorkflowConditionBuilder({
});
};
const openFieldEditor = (field: (typeof fields)[number]) => {
if (editorState.custom && value.trim()) {
setPendingBasicField(field);
return;
}
showFieldEditor(field);
};
const replaceAdvancedExpression = () => {
const field = pendingBasicField;
setPendingBasicField(null);
if (!field) return;
setEditorState({ custom: false, editors: [] });
setAdvancedDraft("");
onChange("");
setActiveEditor({
kind: "field",
draft: {
field: field.value,
operator: defaultConditionOperatorForField(field.value),
value: "",
webhookField: "",
},
});
};
const updateFieldEditor = (draft: ParsedConditionExpression) => {
const isComplete =
(draft.field !== "webhook_field" ||
@@ -480,10 +536,7 @@ export function WorkflowConditionBuilder({
const updateCustomEditor = (draft: string) => {
const trimmed = draft.trim();
setActiveEditor({
kind: "custom",
draft,
});
setAdvancedDraft(draft);
if (trimmed) {
setEditorState({ custom: true, editors: [] });
onChange(trimmed);
@@ -494,115 +547,149 @@ export function WorkflowConditionBuilder({
};
return (
<div className="divide-y divide-border/50">
{fields.map((field) => {
const editor = editorState.custom
? undefined
: editorState.editors.find(
(candidate) => candidate.field === field.value,
);
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>
{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"
<>
<Tabs
className="space-y-3"
onValueChange={(nextMode) => {
const mode = nextMode as ConditionEditorMode;
setEditorMode(mode);
setActiveEditor(null);
if (mode === "advanced") setAdvancedDraft(value);
}}
value={editorMode}
>
<TabsList
aria-label="Condition editor mode"
className="grid h-9 w-full grid-cols-2 p-0.5"
>
<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"
}`}
/>
</button>
<TabsTrigger className="h-8" disabled={disabled} value="basic">
Basic
</TabsTrigger>
<TabsTrigger className="h-8" disabled={disabled} value="advanced">
Advanced
</TabsTrigger>
</TabsList>
{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">
<Input
aria-label="Expression"
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>.
{editorMode === "basic" ? (
<div>
{editorState.custom ? (
<p className="border-b border-border/50 pb-3 text-xs text-muted-foreground">
An advanced expression is active. Choosing a basic filter will
replace it.
</p>
) : null}
<div className="divide-y divide-border/50">
{fields.map((field) => {
const editor = editorState.custom
? undefined
: editorState.editors.find(
(candidate) => candidate.field === field.value,
);
const isExpanded = 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>
{isExpanded && activeEditor ? (
<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>
</div>
) : null}
</div>
</div>
) : (
<div className="animate-in space-y-2 fade-in duration-150 motion-reduce:animate-none">
<FieldLabel htmlFor={`${idPrefix}-advanced-expression`}>
Expression
</FieldLabel>
<Input
aria-label="Advanced expression"
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${idPrefix}-advanced-expression`}
onChange={(event) => updateCustomEditor(event.target.value)}
placeholder='e.g. str_contains(trigger_text, "deploy")'
value={advancedDraft}
/>
<p className="text-xs text-muted-foreground">
Use an evalexpr expression with <code>{evalexprFields}</code>.
</p>
</div>
)}
</Tabs>
<AlertDialog
onOpenChange={(open) => {
if (!open) setPendingBasicField(null);
}}
open={pendingBasicField !== null}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Replace advanced expression?</AlertDialogTitle>
<AlertDialogDescription>
Choosing a basic filter will replace the entire advanced
expression. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Keep advanced expression
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={replaceAdvancedExpression}
type="button"
variant="destructive"
>
Replace expression
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -276,6 +276,7 @@ export function WorkflowDialog({
mutation.reset();
setSelectedChannelId(value);
}}
required
value={selectedChannelId}
/>
{channels.length === 0 ? (