From 1645034e1a5d10dfcfd0d7e23ba052a8e001923d Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 29 May 2026 20:17:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(flows):=20canvas=20/=20mind-map=20view=20(?= =?UTF-8?q?PR=202a=20of=203=20=E2=80=94=20editable=20foundation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on PR 1's read-only canvas. This PR brings the canvas to ~75% of list-view parity: drag-to-reposition (persisted) and click-to-edit via a side panel that mounts the same per-node forms the list view uses. Drag-to-connect + add/delete from canvas land in PR 2b. Architecture (the bulk of the diff): - Extracted the per-node forms (SendButtonsForm, SendListForm, SendMediaForm, ConditionForm, SetTagForm + inline forms for the smaller types) plus the shared field components (TextRow, NextNodeRow, NodeKeySelect) from flow-builder.tsx into src/components/flows/forms/. Both views now mount the SAME NodeConfigForm dispatcher so behaviour can't drift between "what the list editor shows" and "what the canvas side panel shows". - Lifted all editor state into a context/hook (src/components/flows/flow-editor-state.tsx) with a FlowEditorProvider that wraps both views in flow-editor-shell. Single source of truth: toggling Canvas ⇄ List never resets unsaved edits, and a drag on the canvas updates the same nodes array the list view reads. - flow-builder.tsx shrinks from ~2400 lines to ~900 (extracted forms) and stops owning state (now consumes via useFlowEditor()). List-only UI state — expanded cards, scroll refs, jump-to-node flash — stays internal. Canvas additions: - onNodeDragStop writes the new position to context via updateNodePosition (flips dirty; serialized in the existing PUT /api/flows/[id] body — the route already accepts position_x / position_y per migration 010). - onNodeClick opens a controlled shadcn Sheet (right-side panel) with the per-node form, plus "Set as entry" and "Delete node" buttons. Esc / overlay / close button all close. What's intentionally still NOT in this PR (PR 2b): - Per-slot Handle components on the custom node renderer. - Drag-to-connect handler that maps target → updated next_node_key for the right slot. - Floating "+" button to add nodes from canvas. - Keyboard / context-menu delete. Tests: 9 new pure-helper tests (uniqueNodeKey + defaultConfigFor self-consistency). Hook integration tests are deferred — would require @testing-library/react + jsdom infra. Existing 271 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/flows/flow-builder.tsx | 1478 +---------------- src/components/flows/flow-canvas.tsx | 210 ++- src/components/flows/flow-editor-shell.tsx | 51 +- .../flows/flow-editor-state.test.ts | 91 + src/components/flows/flow-editor-state.tsx | 479 ++++++ src/components/flows/forms/fields.tsx | 134 ++ .../flows/forms/node-config-form.tsx | 1076 ++++++++++++ src/components/flows/shared.tsx | 19 + 8 files changed, 2065 insertions(+), 1473 deletions(-) create mode 100644 src/components/flows/flow-editor-state.test.ts create mode 100644 src/components/flows/flow-editor-state.tsx create mode 100644 src/components/flows/forms/fields.tsx create mode 100644 src/components/flows/forms/node-config-form.tsx diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 9b3eb70..1a0c56c 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -15,7 +15,7 @@ * the same file as small components rather than separate modules. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft, @@ -31,16 +31,10 @@ import { CornerDownRight, PauseCircle, PlayCircle, - Paperclip, Workflow, - Upload, - X, } from "lucide-react"; -import { toast } from "sonner"; - import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Select, @@ -56,23 +50,20 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { - validateFlowForActivation, - type ValidationIssue, -} from "@/lib/flows/validate"; -import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; -import { createClient } from "@/lib/supabase/client"; +import { type ValidationIssue } from "@/lib/flows/validate"; import { NODE_META, + slugify, summarizeNode, type BuilderNode, type NodeType, } from "./shared"; - -interface FlowBuilderProps { - initialFlow: FlowRow; - initialNodes: FlowNodeRow[]; -} +import { NodeConfigForm } from "./forms/node-config-form"; +import { NodeKeySelect } from "./forms/fields"; +import { + useFlowEditor, + type BuilderState, +} from "./flow-editor-state"; // ============================================================ // Local state shape — mirrors the DB but the configs are typed @@ -80,319 +71,61 @@ interface FlowBuilderProps { // different shape. The sub-form components narrow as needed. // ============================================================ -interface BuilderState { - name: string; - description: string; - trigger_type: "keyword" | "first_inbound_message" | "manual"; - trigger_config: Record; - entry_node_id: string | null; - status: FlowRow["status"]; - nodes: BuilderNode[]; -} - -// ============================================================ -// Helpers -// ============================================================ - -function slugify(s: string, fallback: string): string { - const cleaned = s - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); - return cleaned || fallback; -} - -function uniqueNodeKey(base: string, existing: BuilderNode[]): string { - if (!existing.some((n) => n.node_key === base)) return base; - let i = 2; - while (existing.some((n) => n.node_key === `${base}_${i}`)) i += 1; - return `${base}_${i}`; -} - -function defaultConfigFor(type: NodeType): Record { - switch (type) { - case "start": - return { next_node_key: "" }; - case "send_message": - return { text: "", next_node_key: "" }; - case "send_buttons": - return { - text: "", - buttons: [{ reply_id: "yes", title: "Yes", next_node_key: "" }], - }; - case "send_list": - return { - text: "", - button_label: "View options", - sections: [ - { - title: "", - rows: [ - { reply_id: "row_1", title: "Option 1", next_node_key: "" }, - ], - }, - ], - }; - case "send_media": - return { - media_type: "image", - media_url: "", - caption: "", - filename: "", - next_node_key: "", - }; - case "collect_input": - return { - prompt_text: "", - var_key: "answer", - next_node_key: "", - }; - case "condition": - return { - subject: "var", - subject_key: "", - operator: "equals", - value: "", - true_next: "", - false_next: "", - }; - case "set_tag": - return { mode: "add", tag_id: "", next_node_key: "" }; - case "handoff": - return { note: "" }; - case "end": - return {}; - } -} - // ============================================================ // Root component // ============================================================ -export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { +export function FlowBuilder() { const router = useRouter(); + const { + flow, + state, + setState, + dirty, + saving, + activating, + issues, + canActivate, + addNode: addNodeCtx, + updateNode, + updateNodeConfig, + removeNode: removeNodeCtx, + save: handleSave, + setStatus: handleStatus, + deleteFlow: handleDelete, + } = useFlowEditor(); - const [state, setState] = useState(() => ({ - name: initialFlow.name, - description: initialFlow.description ?? "", - trigger_type: initialFlow.trigger_type, - trigger_config: initialFlow.trigger_config as Record, - entry_node_id: initialFlow.entry_node_id, - status: initialFlow.status, - nodes: initialNodes.map((n) => ({ - node_key: n.node_key, - node_type: n.node_type as NodeType, - config: n.config as Record, - })), - })); - - const [saving, setSaving] = useState(false); - const [activating, setActivating] = useState(false); + // List-only UI state: which cards are expanded, scroll refs for + // jump-to-node, and the brief border flash that lands the eye on a + // jumped-to node. Canvas-view has its own analogue (selected-node + // + side-sheet open). const [expanded, setExpanded] = useState>( - () => new Set(initialNodes.map((n) => n.node_key)), + () => new Set(state.nodes.map((n) => n.node_key)), ); - // Tracks whether the in-memory state has user edits that haven't been - // PUT yet. We use a wrapper setState (`setStateDirty`) for user edits; - // status-only changes after the activate API succeeds use raw setState - // so they don't falsely re-flag the form as dirty. - const [dirty, setDirty] = useState(false); - const setStateDirty = useCallback((updaterOrValue) => { - setDirty(true); - setState(updaterOrValue); - }, []); - - // Used by jumpToNode() to scroll the target into view + flash its border. const nodeRefs = useRef>(new Map()); const [flashedKey, setFlashedKey] = useState(null); - // Browser-level reload / tab-close / external-link guard. SPA - // navigation (sidebar links, back button) isn't covered here — Next 16 - // routes through the App Router and beforeunload doesn't fire on - // client-side route changes. That's a follow-up; this catches the - // accidental refresh / closed-window class of data loss. - useEffect(() => { - if (!dirty) return; - const handler = (e: BeforeUnloadEvent) => { - e.preventDefault(); - // Modern browsers ignore the return value but require something - // truthy to actually show the native prompt. - e.returnValue = ""; - }; - window.addEventListener("beforeunload", handler); - return () => window.removeEventListener("beforeunload", handler); - }, [dirty]); - - // ---- Validation ---- - const issues = useMemo( - () => - validateFlowForActivation( - { - name: state.name, - trigger_type: state.trigger_type, - trigger_config: state.trigger_config, - entry_node_id: state.entry_node_id, - }, - state.nodes, - ), - [state], - ); - const blockers = issues.filter((i) => i.severity === "error"); - const canActivate = blockers.length === 0; - - // ---- Save (PUT) ---- - const handleSave = useCallback(async () => { - setSaving(true); - try { - const res = await fetch(`/api/flows/${initialFlow.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: state.name, - description: state.description || null, - trigger_type: state.trigger_type, - trigger_config: state.trigger_config, - entry_node_id: state.entry_node_id, - nodes: state.nodes, - }), - }); - if (!res.ok) { - const json = await res.json().catch(() => ({})); - throw new Error(json.error ?? `Save failed: ${res.status}`); - } - setDirty(false); - toast.success("Saved."); - } catch (err) { - const msg = err instanceof Error ? err.message : "Save failed"; - toast.error(msg); - } finally { - setSaving(false); - } - }, [initialFlow.id, state]); - - // ---- Activate / Pause / Archive ---- - const handleStatus = useCallback( - async (next: BuilderState["status"]) => { - if (next === "active" && !canActivate) { - toast.error("Fix the issues below before activating."); - return; - } - setActivating(true); - try { - // Always save first so the activation validator sees the - // latest state — the user shouldn't have to remember "save - // then activate". - if (next === "active") { - await handleSave(); - } - const res = await fetch(`/api/flows/${initialFlow.id}/activate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: next }), - }); - if (!res.ok) { - const json = await res.json().catch(() => ({})); - throw new Error(json.error ?? `Status update failed: ${res.status}`); - } - setState((s) => ({ ...s, status: next })); - toast.success( - next === "active" - ? "Flow activated." - : next === "archived" - ? "Archived." - : "Saved as draft.", - ); - } catch (err) { - const msg = err instanceof Error ? err.message : "Status update failed"; - toast.error(msg); - } finally { - setActivating(false); - } - }, - [canActivate, handleSave, initialFlow.id], - ); - - // ---- Delete ---- - const handleDelete = useCallback(async () => { - const yes = window.confirm( - `Delete "${state.name}"? Any active runs end immediately. This can't be undone.`, - ); - if (!yes) return; - try { - const res = await fetch(`/api/flows/${initialFlow.id}`, { - method: "DELETE", - }); - if (!res.ok) throw new Error(`Delete failed: ${res.status}`); - router.push("/flows"); - } catch (err) { - const msg = err instanceof Error ? err.message : "Delete failed"; - toast.error(msg); - } - }, [initialFlow.id, router, state.name]); - - // ---- Node helpers ---- - const updateNode = useCallback( - (key: string, patch: Partial) => { - setStateDirty((s) => ({ - ...s, - nodes: s.nodes.map((n) => (n.node_key === key ? { ...n, ...patch } : n)), - })); - }, - [setStateDirty], - ); - - const updateNodeConfig = useCallback( - (key: string, configPatch: Record) => { - setStateDirty((s) => ({ - ...s, - nodes: s.nodes.map((n) => - n.node_key === key ? { ...n, config: { ...n.config, ...configPatch } } : n, - ), - })); - }, - [setStateDirty], - ); - + // Wrap addNode so the new node opens expanded in the list view + // (matches the previous behaviour where adding always revealed the + // new card so the user could start editing immediately). const addNode = useCallback( (type: NodeType) => { - const meta = NODE_META[type]; - const base = slugify(meta.label, type); - setStateDirty((s) => { - const node_key = uniqueNodeKey(base, s.nodes); - const next: BuilderNode = { - node_key, - node_type: type, - config: defaultConfigFor(type), - }; - setExpanded((prev) => new Set([...prev, node_key])); - return { - ...s, - nodes: [...s.nodes, next], - // If this is the first node and it's a start, pick it as - // the entry automatically. Saves a click. - entry_node_id: - s.entry_node_id ?? - (type === "start" ? node_key : s.entry_node_id ?? null), - }; - }); + const key = addNodeCtx(type); + setExpanded((prev) => new Set([...prev, key])); }, - [setStateDirty], + [addNodeCtx], ); const removeNode = useCallback( (key: string) => { - setStateDirty((s) => ({ - ...s, - nodes: s.nodes.filter((n) => n.node_key !== key), - entry_node_id: s.entry_node_id === key ? null : s.entry_node_id, - })); + removeNodeCtx(key); setExpanded((prev) => { const next = new Set(prev); next.delete(key); return next; }); }, - [setStateDirty], + [removeNodeCtx], ); const toggleExpanded = useCallback((key: string) => { @@ -439,7 +172,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) {
router.push("/flows")} - onViewRuns={() => router.push(`/flows/${initialFlow.id}/runs`)} + onViewRuns={() => router.push(`/flows/${flow.id}/runs`)} /> i.scope === "trigger")} /> - +
@@ -491,7 +224,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { onUpdateConfig={(patch) => updateNodeConfig(node.node_key, patch)} onRemove={() => removeNode(node.node_key)} onSetEntry={() => - setStateDirty((s) => ({ ...s, entry_node_id: node.node_key })) + setState((s) => ({ ...s, entry_node_id: node.node_key })) } /> )) @@ -864,7 +597,7 @@ function NodeCard({ {expanded && (
-
- - ); -} - -// ---- send_list form ---- - -interface SendListCfg { - text?: string; - button_label?: string; - footer_text?: string; - sections?: Array<{ - title?: string; - rows: Array<{ - reply_id: string; - title: string; - description?: string; - next_node_key: string; - }>; - }>; -} - -function SendListForm({ - cfg, - allNodes, - currentKey, - onUpdateConfig, - showAdvanced, -}: { - cfg: SendListCfg; - allNodes: BuilderNode[]; - currentKey: string; - onUpdateConfig: (patch: Record) => void; - showAdvanced: boolean; -}) { - const sections = cfg.sections ?? []; - const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0); - - const updateSection = ( - sIdx: number, - patch: Partial[number]>, - ) => { - onUpdateConfig({ - sections: sections.map((s, i) => - i === sIdx ? { ...s, ...patch } : s, - ), - }); - }; - const addSection = () => - onUpdateConfig({ - sections: [ - ...sections, - { - title: "", - rows: [ - { - reply_id: `row_${totalRows + 1}`, - title: `Option ${totalRows + 1}`, - next_node_key: "", - }, - ], - }, - ], - }); - const removeSection = (sIdx: number) => - onUpdateConfig({ sections: sections.filter((_, i) => i !== sIdx) }); - const updateRow = ( - sIdx: number, - rIdx: number, - patch: Partial< - NonNullable[number]["rows"][number] - >, - ) => { - onUpdateConfig({ - sections: sections.map((s, i) => - i === sIdx - ? { - ...s, - rows: s.rows.map((r, j) => (j === rIdx ? { ...r, ...patch } : r)), - } - : s, - ), - }); - }; - const addRow = (sIdx: number) => - onUpdateConfig({ - sections: sections.map((s, i) => - i === sIdx - ? { - ...s, - rows: [ - ...s.rows, - { - reply_id: `row_${totalRows + 1}`, - title: `Option ${totalRows + 1}`, - next_node_key: "", - }, - ], - } - : s, - ), - }); - const removeRow = (sIdx: number, rIdx: number) => - onUpdateConfig({ - sections: sections.map((s, i) => - i === sIdx ? { ...s, rows: s.rows.filter((_, j) => j !== rIdx) } : s, - ), - }); - - return ( - <> - onUpdateConfig({ text: v })} - rows={3} - /> -
- onUpdateConfig({ button_label: v })} - /> - onUpdateConfig({ footer_text: v })} - /> -
- -
- - {sections.map((section, sIdx) => ( -
-
- - updateSection(sIdx, { title: e.target.value }) - } - placeholder={`Section ${sIdx + 1} title (optional)`} - className="bg-slate-800 text-xs" - /> - {sections.length > 1 && ( - - )} -
- {section.rows.map((row, rIdx) => ( -
- {showAdvanced && ( - - updateRow(sIdx, rIdx, { - reply_id: slugify( - e.target.value, - `row_${rIdx + 1}`, - ), - }) - } - placeholder="reply_id" - className="bg-slate-800 font-mono text-xs" - /> - )} - - updateRow(sIdx, rIdx, { title: e.target.value }) - } - placeholder="Row title (≤24)" - className="bg-slate-800" - maxLength={24} - /> - - updateRow(sIdx, rIdx, { next_node_key: v ?? "" }) - } - placeholder="Next node…" - /> - -
- ))} - {totalRows < 10 && ( - - )} -
- ))} - {/* WhatsApp's interactive-list spec caps sections at 10. Group rows - by category (Billing / Support / Sales etc.) to give customers a - scannable menu. */} - {sections.length < 10 && ( - - )} -
- - ); -} - -// ---- condition form ---- - -interface ConditionCfg { - subject?: "var" | "tag" | "contact_field"; - subject_key?: string; - operator?: "equals" | "contains" | "present" | "absent"; - value?: string; - true_next?: string; - false_next?: string; -} - -interface UserTag { - id: string; - name: string; - color?: string; -} - -function ConditionForm({ - cfg, - allNodes, - currentKey, - onUpdateConfig, -}: { - cfg: ConditionCfg; - allNodes: BuilderNode[]; - currentKey: string; - onUpdateConfig: (patch: Record) => void; -}) { - const [tags, setTags] = useState([]); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const res = await fetch("/api/tags").catch(() => null); - if (!res || !res.ok) return; - const json = (await res.json()) as { tags?: UserTag[] }; - if (!cancelled) setTags(json.tags ?? []); - } catch { - // Tags endpoint absent on older deployments — fall back to a - // plain text input so the condition is still authorable. - } - })(); - return () => { - cancelled = true; - }; - }, []); - - const subject = cfg.subject ?? "var"; - const operator = cfg.operator ?? "equals"; - const showValue = operator === "equals" || operator === "contains"; - - return ( - <> -
-
- - -
-
- - {subject === "tag" && tags.length > 0 ? ( - - ) : subject === "contact_field" ? ( - - ) : ( - onUpdateConfig({ subject_key: e.target.value })} - placeholder={subject === "var" ? "e.g. email" : "tag UUID"} - className="bg-slate-800 font-mono text-xs" - /> - )} -
-
- -
-
- - -
- {showValue && ( -
- - onUpdateConfig({ value: e.target.value })} - className="bg-slate-800" - /> -
- )} -
- -
- onUpdateConfig({ true_next: v })} - label="If true → advance to" - /> - onUpdateConfig({ false_next: v })} - label="If false → advance to" - /> -
- - ); -} - -// ---- set_tag form ---- - -interface SetTagCfg { - mode?: "add" | "remove"; - tag_id?: string; - next_node_key?: string; -} - -function SetTagForm({ - cfg, - allNodes, - currentKey, - onUpdateConfig, -}: { - cfg: SetTagCfg; - allNodes: BuilderNode[]; - currentKey: string; - onUpdateConfig: (patch: Record) => void; -}) { - const [tags, setTags] = useState([]); - useEffect(() => { - let cancelled = false; - (async () => { - try { - const res = await fetch("/api/tags").catch(() => null); - if (!res || !res.ok) return; - const json = (await res.json()) as { tags?: UserTag[] }; - if (!cancelled) setTags(json.tags ?? []); - } catch { - // No tags endpoint — fall back to raw UUID input. - } - })(); - return () => { - cancelled = true; - }; - }, []); - - return ( - <> -
-
- - -
-
- - {tags.length > 0 ? ( - - ) : ( - onUpdateConfig({ tag_id: e.target.value })} - placeholder="Tag UUID" - className="bg-slate-800 font-mono text-xs" - /> - )} -
-
- onUpdateConfig({ next_node_key: v })} - label="Then advance to" - /> - - ); -} - -// ---- send_media form ---- - -interface SendMediaCfg { - media_type?: "image" | "video" | "document"; - media_url?: string; - caption?: string; - filename?: string; - next_node_key?: string; -} - -// Mirrors the bucket's allowed_mime_types from migration 016. Kept in -// sync with the storage policy so the picker rejects unsupported files -// before they hit the network rather than failing with a confusing -// Supabase RLS / mime-type error. -const MEDIA_ACCEPT: Record, string> = { - image: "image/png,image/jpeg,image/webp", - video: "video/mp4,video/3gpp", - document: - "application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain", -}; - -const FLOW_MEDIA_BUCKET = "flow-media"; -// 16 MB — matches the bucket file_size_limit set in migration 016. -const FLOW_MEDIA_MAX_BYTES = 16 * 1024 * 1024; - -function SendMediaForm({ - cfg, - allNodes, - currentKey, - onUpdateConfig, -}: { - cfg: SendMediaCfg; - allNodes: BuilderNode[]; - currentKey: string; - onUpdateConfig: (patch: Record) => void; -}) { - const fileInputRef = useRef(null); - const [uploading, setUploading] = useState(false); - - const mediaType = cfg.media_type ?? "image"; - const isDocument = mediaType === "document"; - const displayName = - cfg.filename || - (cfg.media_url ? cfg.media_url.split("/").pop() ?? "" : ""); - - const handleFile = useCallback( - async (file: File) => { - if (file.size > FLOW_MEDIA_MAX_BYTES) { - toast.error( - `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`, - ); - return; - } - setUploading(true); - try { - const supabase = createClient(); - const { - data: { user }, - error: userErr, - } = await supabase.auth.getUser(); - if (userErr || !user) { - throw new Error("Not signed in."); - } - // Path convention matches migration 016's RLS policy: first - // segment must equal auth.uid(). Timestamp + random suffix - // prevents collisions between two builders open at once. - const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin"; - const safeBase = file.name - .replace(/\.[^.]+$/, "") - .replace(/[^a-zA-Z0-9_-]+/g, "_") - .slice(0, 40) || "file"; - const path = `${user.id}/${Date.now()}-${safeBase}.${ext}`; - const { error: upErr } = await supabase.storage - .from(FLOW_MEDIA_BUCKET) - .upload(path, file, { - cacheControl: "3600", - upsert: false, - contentType: file.type, - }); - if (upErr) throw new Error(upErr.message); - const { - data: { publicUrl }, - } = supabase.storage.from(FLOW_MEDIA_BUCKET).getPublicUrl(path); - // Patch all three fields in one call so the form doesn't - // re-render with a half-uploaded state. - onUpdateConfig({ - media_url: publicUrl, - filename: file.name, - }); - toast.success("File uploaded."); - } catch (err) { - const msg = err instanceof Error ? err.message : "Upload failed."; - toast.error(msg); - } finally { - setUploading(false); - } - }, - [onUpdateConfig], - ); - - const handleClear = () => { - onUpdateConfig({ media_url: "", filename: "" }); - }; - - return ( - <> -
- - -
- -
- - {cfg.media_url ? ( - - ) : ( - - )} - { - const f = e.target.files?.[0]; - if (f) void handleFile(f); - // Reset so picking the same file twice still fires onChange. - e.target.value = ""; - }} - /> -
- - onUpdateConfig({ caption: v })} - rows={2} - /> - - {isDocument && ( -
- - onUpdateConfig({ filename: e.target.value })} - placeholder="invoice.pdf" - className="bg-slate-800 text-xs" - /> -
- )} - - onUpdateConfig({ next_node_key: v })} - label="After sending, advance to" - /> - - ); -} - -// ---- Smaller field components ---- - -function TextRow({ - label, - value, - onChange, - rows = 1, -}: { - label: string; - value: string; - onChange: (v: string) => void; - rows?: number; -}) { - return ( -
- - {rows > 1 ? ( -