fix(automations): pick tags, agents & templates by name in the builder

The automation builder exposed several developer-facing fields that
made automations hard to configure from the UI (issue #216):

- Keyword Match parsed/trimmed on every keystroke, so typing a
  comma-separated, multi-word list like "SEO, search engine
  optimization" was impossible — the comma and trailing space were
  stripped immediately.
- Tag Added / Add Tag / Remove Tag asked for a raw tag_id.
- Assign Conversation → Specific agent asked for a raw agent_id.
- Send Template asked for a manually typed template name + language.

Changes:
- Keyword input keeps a local draft and only parses into the keywords
  array on blur / Enter, then re-displays the cleaned form.
- New tag / agent / template pickers select existing resources by name
  while still storing ids / names internally, so the stored config
  shape (and the engine/validation) is unchanged. Resources load once
  at the builder root and are shared via context.
- Tags & approved templates load from Supabase (RLS-scoped, matching
  the broadcast picker); members load from /api/account/members.
- Every picker falls back to its original raw input when its list is
  empty (fresh account / older deployment), so automations stay
  authorable. Saved-but-missing ids are preserved when editing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-08 13:21:44 +03:00
parent 151a5dae95
commit efd72b131e
No known key found for this signature in database

View File

@ -1,6 +1,12 @@
"use client" "use client"
import { useState } from "react" import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
@ -37,10 +43,14 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import type { import type {
AccountMember,
AutomationStepType, AutomationStepType,
AutomationTriggerType, AutomationTriggerType,
KeywordMatchTriggerConfig, KeywordMatchTriggerConfig,
MessageTemplate,
Tag as TagRecord,
} from "@/types" } from "@/types"
import { createClient } from "@/lib/supabase/client"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// ------------------------------------------------------------ // ------------------------------------------------------------
@ -155,6 +165,253 @@ function blankConfig(type: AutomationStepType): Record<string, unknown> {
} }
} }
// ------------------------------------------------------------
// Account resources (tags, members, approved templates)
//
// Loaded once at the builder root and shared via context so the
// tag / agent / template pickers below can offer existing resources
// by name instead of asking the user to paste raw UUIDs. Every picker
// falls back to a raw input when its list is empty (fresh account or
// an older deployment), so an automation is always authorable.
// ------------------------------------------------------------
interface AutomationResources {
tags: TagRecord[]
members: AccountMember[]
templates: MessageTemplate[]
}
const ResourcesContext = createContext<AutomationResources>({
tags: [],
members: [],
templates: [],
})
function useResources(): AutomationResources {
return useContext(ResourcesContext)
}
function ResourcesProvider({ children }: { children: ReactNode }) {
const [tags, setTags] = useState<TagRecord[]>([])
const [members, setMembers] = useState<AccountMember[]>([])
const [templates, setTemplates] = useState<MessageTemplate[]>([])
useEffect(() => {
let cancelled = false
const supabase = createClient()
// Tags and templates come straight from the DB — RLS scopes both
// to the caller's account. Only APPROVED templates can actually be
// sent (anything else 400s at send time), matching the broadcast
// picker.
void (async () => {
const [tagsRes, templatesRes] = await Promise.all([
supabase.from("tags").select("*").order("name"),
supabase
.from("message_templates")
.select("*")
.eq("status", "APPROVED")
.order("name"),
])
if (cancelled) return
setTags((tagsRes.data as TagRecord[] | null) ?? [])
setTemplates((templatesRes.data as MessageTemplate[] | null) ?? [])
})()
// Members go through the API so we inherit its email-visibility
// rules (agents/viewers don't see emails). Unreachable on older
// deployments → pickers fall back to a raw agent-id input.
void (async () => {
try {
const res = await fetch("/api/account/members", { cache: "no-store" })
if (!res.ok) return
const json = (await res.json()) as { members?: AccountMember[] }
if (!cancelled) setMembers(json.members ?? [])
} catch {
// Members endpoint absent — caller falls back to raw input.
}
})()
return () => {
cancelled = true
}
}, [])
return (
<ResourcesContext.Provider value={{ tags, members, templates }}>
{children}
</ResourcesContext.Provider>
)
}
const SELECT_CLASS =
"w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:border-primary focus:outline-none"
/** Tag dropdown by name + color, storing the tag's id. Falls back to a
* raw id input when no tags exist yet. */
function TagSelect({
value,
onChange,
}: {
value: string
onChange: (v: string) => void
}) {
const { tags } = useResources()
if (tags.length === 0) {
return (
<Input
placeholder="Tag id"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-slate-800 text-white"
/>
)
}
const selected = tags.find((t) => t.id === value)
return (
<div className="flex items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-full border border-slate-600"
style={{ backgroundColor: selected?.color ?? "transparent" }}
aria-hidden
/>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS}
>
<option value="">Select a tag…</option>
{tags.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
{/* Preserve a saved tag that's since been deleted so editing an
existing automation doesn't silently drop it. */}
{value && !selected && (
<option value={value}>{value} (unknown tag)</option>
)}
</select>
</div>
)
}
/** Agent dropdown by name, storing the member's user_id. Falls back to
* a raw id input when the member list is unavailable. */
function AgentSelect({
value,
onChange,
}: {
value: string
onChange: (v: string) => void
}) {
const { members } = useResources()
if (members.length === 0) {
return (
<Input
placeholder="Agent id"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-slate-800 text-white"
/>
)
}
const selected = members.find((m) => m.user_id === value)
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS}
>
<option value="">Select an agent…</option>
{members.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.full_name || m.email || m.user_id}
</option>
))}
{value && !selected && (
<option value={value}>{value} (unknown agent)</option>
)}
</select>
)
}
/** Template dropdown showing approved templates by name + language,
* storing both template_name and language. Falls back to manual name +
* language inputs when no approved templates are synced yet. */
function SendTemplateFields({
templateName,
language,
onChange,
}: {
templateName: string
language: string
onChange: (patch: { template_name: string; language: string }) => void
}) {
const { templates } = useResources()
if (templates.length === 0) {
return (
<>
<FieldBlock label="Template name">
<Input
value={templateName}
onChange={(e) =>
onChange({ template_name: e.target.value, language })
}
className="bg-slate-800 text-white"
/>
</FieldBlock>
<FieldBlock label="Language">
<Input
value={language}
onChange={(e) =>
onChange({ template_name: templateName, language: e.target.value })
}
className="bg-slate-800 text-white"
/>
</FieldBlock>
</>
)
}
// Encode name + language in the option value so two templates that
// share a name across languages stay distinct.
const toValue = (name: string, lang: string) => `${name}::${lang}`
const current = templateName ? toValue(templateName, language) : ""
const hasMatch = templates.some(
(t) => toValue(t.name, t.language ?? "en_US") === current,
)
return (
<FieldBlock label="Template">
<select
value={current}
onChange={(e) => {
const [name, lang] = e.target.value.split("::")
onChange({ template_name: name ?? "", language: lang ?? "" })
}}
className={SELECT_CLASS}
>
<option value="">Select a template…</option>
{templates.map((t) => {
const lang = t.language ?? "en_US"
return (
<option key={t.id} value={toValue(t.name, lang)}>
{t.name} ({lang})
</option>
)
})}
{current && !hasMatch && (
<option value={current}>
{templateName} ({language || "unknown"}) — not in approved list
</option>
)}
</select>
</FieldBlock>
)
}
// ------------------------------------------------------------ // ------------------------------------------------------------
// Main builder component // Main builder component
// ------------------------------------------------------------ // ------------------------------------------------------------
@ -286,6 +543,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
<div className="relative flex-1 overflow-y-auto"> <div className="relative flex-1 overflow-y-auto">
<div className="absolute inset-0 bg-[radial-gradient(circle,#1e293b_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" /> <div className="absolute inset-0 bg-[radial-gradient(circle,#1e293b_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" />
<div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10"> <div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10">
<ResourcesProvider>
<TriggerCard <TriggerCard
type={state.trigger_type} type={state.trigger_type}
config={state.trigger_config} config={state.trigger_config}
@ -302,6 +560,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
deleteStepAt={deleteStepAt} deleteStepAt={deleteStepAt}
moveStepAt={moveStepAt} moveStepAt={moveStepAt}
/> />
</ResourcesProvider>
</div> </div>
</div> </div>
</div> </div>
@ -375,14 +634,15 @@ function TriggerCard({
/> />
)} )}
{type === "tag_added" && ( {type === "tag_added" && (
<Input <div>
placeholder="Tag id" <label className="mb-1 block text-xs font-medium text-slate-400">
Tag
</label>
<TagSelect
value={(config.tag_id as string) ?? ""} value={(config.tag_id as string) ?? ""}
onChange={(e) => onChange={(v) => onConfigChange({ ...config, tag_id: v })}
onConfigChange({ ...config, tag_id: e.target.value })
}
className="bg-slate-800 text-white"
/> />
</div>
)} )}
{type === "time_based" && ( {type === "time_based" && (
<Input <Input
@ -409,6 +669,23 @@ function KeywordMatchConfig({
onChange: (c: Record<string, unknown>) => void onChange: (c: Record<string, unknown>) => void
}) { }) {
const keywords = config?.keywords ?? [] const keywords = config?.keywords ?? []
// Keep a local draft string so the comma and trailing space aren't
// stripped on every keystroke (which made multi-word, comma-separated
// entry like "SEO, search engine optimization" impossible to type).
// We only parse into the keywords array on blur, then re-display the
// cleaned, rejoined form. Seeded once on mount; this component remounts
// when the trigger type changes, so the seed stays in sync.
const [draft, setDraft] = useState(keywords.join(", "))
function commit() {
const parsed = draft
.split(",")
.map((s) => s.trim())
.filter(Boolean)
setDraft(parsed.join(", "))
onChange({ ...config, keywords: parsed })
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
@ -416,16 +693,16 @@ function KeywordMatchConfig({
Keywords (comma-separated) Keywords (comma-separated)
</label> </label>
<Input <Input
value={keywords.join(", ")} value={draft}
onChange={(e) => onChange={(e) => setDraft(e.target.value)}
onChange({ onBlur={commit}
...config, onKeyDown={(e) => {
keywords: e.target.value if (e.key === "Enter") {
.split(",") e.preventDefault()
.map((s) => s.trim()) commit()
.filter(Boolean),
})
} }
}}
placeholder="e.g. pricing, demo request, talk to sales"
className="bg-slate-800 text-white" className="bg-slate-800 text-white"
/> />
</div> </div>
@ -724,31 +1001,19 @@ function StepEditor({
) )
case "send_template": case "send_template":
return ( return (
<> <SendTemplateFields
<FieldBlock label="Template name"> templateName={(cfg.template_name as string) ?? ""}
<Input language={(cfg.language as string) ?? ""}
value={(cfg.template_name as string) ?? ""} onChange={(patch) => set(patch)}
onChange={(e) => set({ template_name: e.target.value })}
className="bg-slate-800 text-white"
/> />
</FieldBlock>
<FieldBlock label="Language">
<Input
value={(cfg.language as string) ?? ""}
onChange={(e) => set({ language: e.target.value })}
className="bg-slate-800 text-white"
/>
</FieldBlock>
</>
) )
case "add_tag": case "add_tag":
case "remove_tag": case "remove_tag":
return ( return (
<FieldBlock label="Tag id"> <FieldBlock label="Tag">
<Input <TagSelect
value={(cfg.tag_id as string) ?? ""} value={(cfg.tag_id as string) ?? ""}
onChange={(e) => set({ tag_id: e.target.value })} onChange={(v) => set({ tag_id: v })}
className="bg-slate-800 text-white"
/> />
</FieldBlock> </FieldBlock>
) )
@ -766,11 +1031,10 @@ function StepEditor({
</select> </select>
</FieldBlock> </FieldBlock>
{cfg.mode === "specific" && ( {cfg.mode === "specific" && (
<FieldBlock label="Agent id"> <FieldBlock label="Agent">
<Input <AgentSelect
value={(cfg.agent_id as string) ?? ""} value={(cfg.agent_id as string) ?? ""}
onChange={(e) => set({ agent_id: e.target.value })} onChange={(v) => set({ agent_id: v })}
className="bg-slate-800 text-white"
/> />
</FieldBlock> </FieldBlock>
)} )}