Use emoji picker across workflow fields

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-13 14:12:35 -07:00
parent 20073b3a9d
commit 3cf827b844
5 changed files with 185 additions and 94 deletions
@@ -2,6 +2,7 @@ import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import {
buildConditionExpression,
@@ -68,8 +69,6 @@ function valuePlaceholder(field: string): string {
return "Paste a channel UUID";
case "trigger_message_id":
return "Paste a message event ID";
case "trigger_emoji":
return "e.g. 👍";
case "trigger_timestamp":
return "e.g. 1723507200";
default:
@@ -265,21 +264,34 @@ export function WorkflowConditionBuilder({
? "Value"
: valueLabel(editor.field)}
</FieldLabel>
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${idPrefix}-value`}
onChange={(event) =>
emitEditor({ ...editor, value: event.target.value })
}
placeholder={
editor.field === "webhook_field"
? "Value to match"
: valuePlaceholder(editor.field)
}
value={editor.value}
/>
{editor.field === "trigger_emoji" ? (
<WorkflowEmojiField
ariaLabel="Choose condition emoji"
clearAriaLabel="Clear condition emoji"
disabled={disabled}
id={`${idPrefix}-value`}
onChange={(emoji) =>
emitEditor({ ...editor, value: emoji ?? "" })
}
value={editor.value}
/>
) : (
<Input
autoCapitalize="off"
autoCorrect="off"
disabled={disabled}
id={`${idPrefix}-value`}
onChange={(event) =>
emitEditor({ ...editor, value: event.target.value })
}
placeholder={
editor.field === "webhook_field"
? "Value to match"
: valuePlaceholder(editor.field)
}
value={editor.value}
/>
)}
</div>
) : null}
</>
@@ -0,0 +1,82 @@
import { SmilePlus, X } from "lucide-react";
import * as React from "react";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
type WorkflowEmojiFieldProps = {
ariaLabel: string;
clearAriaLabel?: string;
disabled?: boolean;
id: string;
onChange: (emoji: string | undefined) => void;
value?: string;
};
export function WorkflowEmojiField({
ariaLabel,
clearAriaLabel,
disabled,
id,
onChange,
value,
}: WorkflowEmojiFieldProps) {
const [pickerOpen, setPickerOpen] = React.useState(false);
return (
<div className="flex gap-2">
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
<PopoverTrigger asChild>
<Button
aria-label={ariaLabel}
className="flex-1 justify-start px-3 font-normal"
disabled={disabled}
id={id}
type="button"
variant="outline"
>
{value ? (
<>
<StatusEmoji className="h-5 w-5 text-base" value={value} />
<span>{emojiDisplayName(value)}</span>
</>
) : (
<>
<SmilePlus className="text-muted-foreground" />
<span className="text-muted-foreground">Choose a reaction</span>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto overflow-hidden rounded-2xl border-0 bg-transparent p-0 shadow-none"
sideOffset={4}
>
<EmojiPicker
autoFocus
onSelect={(emoji) => {
onChange(emoji);
setPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
{value && clearAriaLabel ? (
<Button
aria-label={clearAriaLabel}
disabled={disabled}
onClick={() => onChange(undefined)}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
) : null}
</div>
);
}
@@ -1,21 +1,10 @@
import {
Check,
ChevronDown,
Plus,
SmilePlus,
Trash2,
X,
Zap,
} from "lucide-react";
import { Check, ChevronDown, Plus, Trash2, X, Zap } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,10 +12,10 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Switch } from "@/shared/ui/switch";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { WorkflowScheduleFields } from "./WorkflowScheduleFields";
import { WorkflowStepCard } from "./WorkflowStepCard";
import { FieldLabel } from "./workflowFormPrimitives";
@@ -57,8 +46,6 @@ function TriggerConfigFields({
trigger: TriggerConfig;
onUpdate: (trigger: TriggerConfig) => void;
}) {
const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false);
switch (trigger.on) {
case "message_posted":
case "diff_posted":
@@ -80,62 +67,14 @@ function TriggerConfigFields({
<FieldLabel htmlFor="wf-trigger-emoji">
Emoji filter (optional)
</FieldLabel>
<div className="flex gap-2">
<Popover onOpenChange={setEmojiPickerOpen} open={emojiPickerOpen}>
<PopoverTrigger asChild>
<Button
aria-label="Choose emoji filter"
className="flex-1 justify-start px-3 font-normal"
disabled={disabled}
id="wf-trigger-emoji"
type="button"
variant="outline"
>
{trigger.emoji ? (
<>
<StatusEmoji
className="h-5 w-5 text-base"
value={trigger.emoji}
/>
<span>{emojiDisplayName(trigger.emoji)}</span>
</>
) : (
<>
<SmilePlus className="text-muted-foreground" />
<span className="text-muted-foreground">
Choose a reaction
</span>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-auto overflow-hidden rounded-2xl border-0 bg-transparent p-0 shadow-none"
sideOffset={4}
>
<EmojiPicker
autoFocus
onSelect={(emoji) => {
onUpdate({ ...trigger, emoji });
setEmojiPickerOpen(false);
}}
/>
</PopoverContent>
</Popover>
{trigger.emoji ? (
<Button
aria-label="Clear emoji filter"
disabled={disabled}
onClick={() => onUpdate({ ...trigger, emoji: undefined })}
size="icon"
type="button"
variant="ghost"
>
<X />
</Button>
) : null}
</div>
<WorkflowEmojiField
ariaLabel="Choose emoji filter"
clearAriaLabel="Clear emoji filter"
disabled={disabled}
id="wf-trigger-emoji"
onChange={(emoji) => onUpdate({ ...trigger, emoji })}
value={trigger.emoji}
/>
</div>
);
case "webhook":
@@ -5,6 +5,7 @@ import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { WorkflowConditionBuilder } from "./WorkflowConditionBuilder";
import { WorkflowEmojiField } from "./WorkflowEmojiField";
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
import type { StepFormState, TriggerType } from "./workflowFormTypes";
@@ -255,14 +256,11 @@ function StepConfigFields({
return (
<div className="space-y-1.5">
<FieldLabel htmlFor={`${prefix}-emoji`}>Emoji</FieldLabel>
<Input
autoCapitalize="off"
<WorkflowEmojiField
ariaLabel="Choose reaction emoji"
disabled={disabled}
id={`${prefix}-emoji`}
onChange={(event) =>
onUpdate({ ...step, emoji: event.target.value })
}
placeholder="e.g. thumbsup"
onChange={(emoji) => onUpdate({ ...step, emoji })}
value={step.emoji ?? ""}
/>
</div>
+60
View File
@@ -292,6 +292,66 @@ test("chooses and clears a reaction trigger with the app emoji picker", async ({
).toContainText("Choose a reaction");
});
test("chooses an add-reaction step emoji with the app emoji picker", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Add Reaction" }).click();
const inspector = dialog.getByTestId("workflow-node-inspector");
const stepEmoji = inspector.getByRole("button", {
name: "Choose reaction emoji",
});
await expect(stepEmoji).toContainText("Choose a reaction");
await stepEmoji.click();
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill("buzz");
await picker.getByRole("button", { name: ":buzz:" }).first().click();
await expect(stepEmoji).toContainText(":buzz:");
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/action: add_reaction[\s\S]*emoji: ":buzz:"/,
);
});
test("chooses a reaction emoji condition with the app emoji picker", async ({
page,
}) => {
await navigateToWorkflows(page);
await page.getByRole("button", { name: "Create Workflow" }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: /^Trigger:/ }).click();
let inspector = dialog.getByTestId("workflow-node-inspector");
await inspector.getByLabel("Trigger event").click();
await page.getByRole("menuitem", { name: "Reaction Added" }).click();
await dialog.getByRole("button", { name: "Add step" }).click();
await page.getByRole("menuitem", { name: "Delay" }).click();
inspector = dialog.getByTestId("workflow-node-inspector");
await inspector.getByRole("button", { name: "Reaction emoji" }).click();
const conditionEmoji = inspector.getByRole("button", {
name: "Choose condition emoji",
});
await conditionEmoji.click();
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill("buzz");
await picker.getByRole("button", { name: ":buzz:" }).first().click();
await expect(conditionEmoji).toContainText(":buzz:");
await dialog.getByRole("tab", { name: "YAML" }).click();
await expect(dialog.getByLabel("Workflow YAML")).toHaveValue(
/trigger_emoji[\s\S]*:buzz:/,
);
});
test("switches an empty workflow between form and YAML modes", async ({
page,
}) => {