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"
|
"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,22 +543,24 @@ 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">
|
||||||
<TriggerCard
|
<ResourcesProvider>
|
||||||
type={state.trigger_type}
|
<TriggerCard
|
||||||
config={state.trigger_config}
|
type={state.trigger_type}
|
||||||
onTypeChange={(t) => patchTop("trigger_type", t)}
|
config={state.trigger_config}
|
||||||
onConfigChange={(c) => patchTop("trigger_config", c)}
|
onTypeChange={(t) => patchTop("trigger_type", t)}
|
||||||
/>
|
onConfigChange={(c) => patchTop("trigger_config", c)}
|
||||||
<StepList
|
/>
|
||||||
steps={state.steps}
|
<StepList
|
||||||
parentPath={[]}
|
steps={state.steps}
|
||||||
expandedId={expandedId}
|
parentPath={[]}
|
||||||
setExpandedId={setExpandedId}
|
expandedId={expandedId}
|
||||||
updateStep={updateStep}
|
setExpandedId={setExpandedId}
|
||||||
addStepAt={addStepAt}
|
updateStep={updateStep}
|
||||||
deleteStepAt={deleteStepAt}
|
addStepAt={addStepAt}
|
||||||
moveStepAt={moveStepAt}
|
deleteStepAt={deleteStepAt}
|
||||||
/>
|
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">
|
||||||
value={(config.tag_id as string) ?? ""}
|
Tag
|
||||||
onChange={(e) =>
|
</label>
|
||||||
onConfigChange({ ...config, tag_id: e.target.value })
|
<TagSelect
|
||||||
}
|
value={(config.tag_id as string) ?? ""}
|
||||||
className="bg-slate-800 text-white"
|
onChange={(v) => onConfigChange({ ...config, tag_id: v })}
|
||||||
/>
|
/>
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user