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);
  },
});