mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
151a5dae95
commit
efd72b131e
@ -1,6 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
@ -37,10 +43,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import type {
|
||||
AccountMember,
|
||||
AutomationStepType,
|
||||
AutomationTriggerType,
|
||||
KeywordMatchTriggerConfig,
|
||||
MessageTemplate,
|
||||
Tag as TagRecord,
|
||||
} from "@/types"
|
||||
import { createClient } from "@/lib/supabase/client"
|
||||
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
|
||||
// ------------------------------------------------------------
|
||||
@ -286,22 +543,24 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
|
||||
<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="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10">
|
||||
<TriggerCard
|
||||
type={state.trigger_type}
|
||||
config={state.trigger_config}
|
||||
onTypeChange={(t) => patchTop("trigger_type", t)}
|
||||
onConfigChange={(c) => patchTop("trigger_config", c)}
|
||||
/>
|
||||
<StepList
|
||||
steps={state.steps}
|
||||
parentPath={[]}
|
||||
expandedId={expandedId}
|
||||
setExpandedId={setExpandedId}
|
||||
updateStep={updateStep}
|
||||
addStepAt={addStepAt}
|
||||
deleteStepAt={deleteStepAt}
|
||||
moveStepAt={moveStepAt}
|
||||
/>
|
||||
<ResourcesProvider>
|
||||
<TriggerCard
|
||||
type={state.trigger_type}
|
||||
config={state.trigger_config}
|
||||
onTypeChange={(t) => patchTop("trigger_type", t)}
|
||||
onConfigChange={(c) => patchTop("trigger_config", c)}
|
||||
/>
|
||||
<StepList
|
||||
steps={state.steps}
|
||||
parentPath={[]}
|
||||
expandedId={expandedId}
|
||||
setExpandedId={setExpandedId}
|
||||
updateStep={updateStep}
|
||||
addStepAt={addStepAt}
|
||||
deleteStepAt={deleteStepAt}
|
||||
moveStepAt={moveStepAt}
|
||||
/>
|
||||
</ResourcesProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -375,14 +634,15 @@ function TriggerCard({
|
||||
/>
|
||||
)}
|
||||
{type === "tag_added" && (
|
||||
<Input
|
||||
placeholder="Tag id"
|
||||
value={(config.tag_id as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onConfigChange({ ...config, tag_id: e.target.value })
|
||||
}
|
||||
className="bg-slate-800 text-white"
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">
|
||||
Tag
|
||||
</label>
|
||||
<TagSelect
|
||||
value={(config.tag_id as string) ?? ""}
|
||||
onChange={(v) => onConfigChange({ ...config, tag_id: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === "time_based" && (
|
||||
<Input
|
||||
@ -409,6 +669,23 @@ function KeywordMatchConfig({
|
||||
onChange: (c: Record<string, unknown>) => void
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
@ -416,16 +693,16 @@ function KeywordMatchConfig({
|
||||
Keywords (comma-separated)
|
||||
</label>
|
||||
<Input
|
||||
value={keywords.join(", ")}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...config,
|
||||
keywords: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
commit()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. pricing, demo request, talk to sales"
|
||||
className="bg-slate-800 text-white"
|
||||
/>
|
||||
</div>
|
||||
@ -724,31 +1001,19 @@ function StepEditor({
|
||||
)
|
||||
case "send_template":
|
||||
return (
|
||||
<>
|
||||
<FieldBlock label="Template name">
|
||||
<Input
|
||||
value={(cfg.template_name as string) ?? ""}
|
||||
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>
|
||||
</>
|
||||
<SendTemplateFields
|
||||
templateName={(cfg.template_name as string) ?? ""}
|
||||
language={(cfg.language as string) ?? ""}
|
||||
onChange={(patch) => set(patch)}
|
||||
/>
|
||||
)
|
||||
case "add_tag":
|
||||
case "remove_tag":
|
||||
return (
|
||||
<FieldBlock label="Tag id">
|
||||
<Input
|
||||
<FieldBlock label="Tag">
|
||||
<TagSelect
|
||||
value={(cfg.tag_id as string) ?? ""}
|
||||
onChange={(e) => set({ tag_id: e.target.value })}
|
||||
className="bg-slate-800 text-white"
|
||||
onChange={(v) => set({ tag_id: v })}
|
||||
/>
|
||||
</FieldBlock>
|
||||
)
|
||||
@ -766,11 +1031,10 @@ function StepEditor({
|
||||
</select>
|
||||
</FieldBlock>
|
||||
{cfg.mode === "specific" && (
|
||||
<FieldBlock label="Agent id">
|
||||
<Input
|
||||
<FieldBlock label="Agent">
|
||||
<AgentSelect
|
||||
value={(cfg.agent_id as string) ?? ""}
|
||||
onChange={(e) => set({ agent_id: e.target.value })}
|
||||
className="bg-slate-800 text-white"
|
||||
onChange={(v) => set({ agent_id: v })}
|
||||
/>
|
||||
</FieldBlock>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user