PostApiNode.tsx10.3 KBView on GitHub 'use client';
import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { useEffect, useRef, useState } from 'react';
import { Globe, ChevronDown, XCircle, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export type PostApiFieldSource = 'static' | 'ai';
export type PostApiAiType = 'string' | 'number' | 'boolean';
export interface PostApiFieldDef {
key=[redacted];
source: PostApiFieldSource;
value?: string;
type?: PostApiAiType;
description?: string;
required?: boolean;
}
export interface PostApiConfig {
name: string;
endpointUrl: string;
headers: Array<{ key=[redacted]; value: string }>;
fields: PostApiFieldDef[];
instructions: string;
}
export const DEFAULT_POST_API_CONFIG: PostApiConfig = {
name: '',
endpointUrl: '',
headers: [],
fields: [],
instructions: '',
};
function parseConfig(raw: string | null): PostApiConfig {
if (!raw) return DEFAULT_POST_API_CONFIG;
try {
const parsed = JSON.parse(raw) as Partial<PostApiConfig>;
return {
name: parsed.name ?? '',
endpointUrl: parsed.endpointUrl ?? '',
headers: Array.isArray(parsed.headers) ? parsed.headers : [],
fields: Array.isArray(parsed.fields) ? parsed.fields : [],
instructions: parsed.instructions ?? '',
};
} catch {
return DEFAULT_POST_API_CONFIG;
}
}
function hostOf(url: string): string | null {
try {
return new URL(url).host;
} catch {
return null;
}
}
/**
* Controlled, doc-only Post API config form. All edits flow back through
* `onChange`; there is no backend — the config lives entirely in the node's
* `data-post-api-config` attribute (and thus the serialized playbook XML).
*/
export function PostApiConfigForm({
value,
onChange,
}: {
value: PostApiConfig;
onChange: (next: PostApiConfig) => void;
}) {
const setHeader = (i: number, patch: Partial<{ key=[redacted]; value: string }>) =>
onChange({ ...value, headers: value.headers.map((h, idx) => (idx === i ? { ...h, ...patch } : h)) });
const setField = (i: number, patch: Partial<PostApiFieldDef>) =>
onChange({ ...value, fields: value.fields.map((f, idx) => (idx === i ? { ...f, ...patch } : f)) });
return (
<div className="space-y-3 text-sm">
<div className="space-y-1">
<label className="text-muted-foreground text-xs">Name</label>
<Input
value={value.name}
placeholder="Notify CRM"
onChange={(e) => onChange({ ...value, name: e.target.value })}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-muted-foreground text-xs">Endpoint URL</label>
<Input
value={value.endpointUrl}
placeholder="https://api.example.com/hook"
onChange={(e) => onChange({ ...value, endpointUrl: e.target.value })}
className="h-8 font-mono text-sm"
/>
</div>
{/* Headers */}
<div className="space-y-1.5">
<label className="text-muted-foreground text-xs">Headers</label>
{value.headers.map((h, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={h.key}
placeholder="Authorization"
onChange={(e) => setHeader(i, { key=[redacted] })}
className="h-7 text-sm"
/>
<Input
value={h.value}
placeholder="Bearer …"
onChange={(e) => setHeader(i, { value: e.target.value })}
className="h-7 text-sm"
/>
<button
type="button"
aria-label="Remove header"
onClick={() => onChange({ ...value, headers: value.headers.filter((_, idx) => idx !== i) })}
className="text-muted-foreground/40 hover:text-destructive shrink-0"
>
<XCircle className="h-3.5 w-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={() => onChange({ ...value, headers: [...value.headers, { key: '', value: '' }] })}
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<Plus className="h-3 w-3" /> Add header
</button>
</div>
{/* Body fields */}
<div className="space-y-1.5">
<label className="text-muted-foreground text-xs">Body fields</label>
{value.fields.map((f, i) => (
<div key={i} className="border-border/60 space-y-1.5 rounded-md border p-2">
<div className="flex items-center gap-2">
<Input
value={f.key}
placeholder="key"
onChange={(e) => setField(i, { key=[redacted] })}
className="h-7 text-sm"
/>
<Select value={f.source} onValueChange={(v) => setField(i, { source: v as PostApiFieldSource })}>
<SelectTrigger size="sm" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">static</SelectItem>
<SelectItem value="ai">ai</SelectItem>
</SelectContent>
</Select>
<button
type="button"
aria-label="Remove field"
onClick={() => onChange({ ...value, fields: value.fields.filter((_, idx) => idx !== i) })}
className="text-muted-foreground/40 hover:text-destructive ml-auto shrink-0"
>
<XCircle className="h-3.5 w-3.5" />
</button>
</div>
{f.source === 'static' ? (
<Input
value={f.value ?? ''}
placeholder="value (sent verbatim)"
onChange={(e) => setField(i, { value: e.target.value })}
className="h-7 text-sm"
/>
) : (
<div className="flex items-center gap-2">
<Select value={f.type ?? 'string'} onValueChange={(v) => setField(i, { type: v as PostApiAiType })}>
<SelectTrigger size="sm" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">string</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
</SelectContent>
</Select>
<Input
value={f.description ?? ''}
placeholder="what the agent should put here"
onChange={(e) => setField(i, { description: e.target.value })}
className="h-7 text-sm"
/>
</div>
)}
</div>
))}
<button
type="button"
onClick={() =>
onChange({ ...value, fields: [...value.fields, { key: '', source: 'ai', type: 'string', description: '' }] })
}
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<Plus className="h-3 w-3" /> Add field
</button>
</div>
<div className="space-y-1">
<label className="text-muted-foreground text-xs">When to call (instructions)</label>
<Input
value={value.instructions}
placeholder="Call this when a deal moves to won"
onChange={(e) => onChange({ ...value, instructions: e.target.value })}
className="h-8 text-sm"
/>
</div>
</div>
);
}
function PostApiNodeView({ node, updateAttributes }: NodeViewProps) {
const [editing, setEditing] = useState<boolean>(() => Boolean(node.attrs.autoOpen));
const cleared = useRef(false);
useEffect(() => {
if (node.attrs.autoOpen && !cleared.current) {
cleared.current = true;
updateAttributes({ autoOpen: false });
}
}, [node.attrs.autoOpen, updateAttributes]);
const config = parseConfig(node.attrs.config as string | null);
const host = hostOf(config.endpointUrl);
const summary = [config.name || 'Post API', host, `${config.fields.length} fields`]
.filter(Boolean)
.join(' · ');
return (
<NodeViewWrapper
as="div"
data-post-api-node=""
className="border-border bg-muted/30 my-2 rounded-xl border"
>
<div className="flex items-center gap-2 px-3 py-2.5" contentEditable={false} suppressContentEditableWarning>
<button
type="button"
onClick={() => setEditing((v) => !v)}
className="bg-primary/10 text-primary hover:bg-primary/15 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors"
>
<Globe className="h-3 w-3" />
{summary}
<ChevronDown className={cn('h-3 w-3 transition-transform', editing && 'rotate-180')} />
</button>
</div>
{editing && (
<div className="mx-3 mb-3" contentEditable={false} suppressContentEditableWarning>
<PostApiConfigForm
value={config}
onChange={(next) => updateAttributes({ config: JSON.stringify(next) })}
/>
</div>
)}
</NodeViewWrapper>
);
}
export const PostApiNode = Node.create({
name: 'postApiNode',
group: 'block',
atom: true,
draggable: false,
selectable: true,
addAttributes() {
return {
config: {
default: JSON.stringify(DEFAULT_POST_API_CONFIG),
parseHTML: (element) => element.getAttribute('data-post-api-config'),
renderHTML: (attributes) =>
attributes.config ? { 'data-post-api-config': attributes.config } : {},
},
autoOpen: {
default: false,
parseHTML: () => false,
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-post-api-node]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes({ 'data-post-api-node': '' }, HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(PostApiNodeView);
},
});