zebflow 0.10.10

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { useState, useEffect, cx } from "zeb/react";
import { Dialog } from "@/components/ui/dialog";
import DialogContent from "@/components/ui/dialog-content";
import DialogHeader from "@/components/ui/dialog-header";
import DialogTitle from "@/components/ui/dialog-title";
import Button from "@/components/ui/button";
import Input from "@/components/ui/input";
import { Select, SelectOption } from "@/components/ui/select";
import { StudioTable, StudioTd, StudioTh, StudioThead } from "@/components/ui/studio-data-table";

/**
 * Edges between rows, for engines whose relation style is `graph`.
 *
 * Declared by `capabilities.relations === "graph"`. Only sekejap uses this
 * today; it is kept engine-neutral for multimodel engines such as ArangoDB
 * and SurrealDB, which express relationships the same way. Engines that
 * declare `foreign_key` render a different panel instead. Writing an edge
 * lives in `relation-create-panel`, through an edge table.
 */

export function relationNodeSlug(record, fallbackCollection = "") {
  const explicit = String(record?._id || record?.slug || "").trim();
  if (explicit) return explicit;
  const collection = String(record?._collection || fallbackCollection || "").trim();
  const key = String(record?._key || "").trim();
  return collection && key ? `${collection}/${key}` : "";
}

export function relationNodeLabel(record, fallbackCollection = "") {
  return (
    String(record?.title || "").trim() ||
    String(record?.fullname || "").trim() ||
    String(record?.full_name || "").trim() ||
    String(record?.name || "").trim() ||
    String(record?.label || "").trim() ||
    String(record?.post_id || "").trim() ||
    String(record?._key || "").trim() ||
    relationNodeSlug(record, fallbackCollection)
  );
}

export function relationCountFromRows(rows) {
  const first = Array.isArray(rows) && Array.isArray(rows[0]) ? rows[0][0] : null;
  const count = Number(first);
  return Number.isFinite(count) ? count : null;
}

export function RelationTargetSearchDialog({ open, onOpenChange, tables, onSearch, onSelect }) {
  const [collection, setCollection] = useState("");
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [status, setStatus] = useState("Choose a collection and search existing nodes.");
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    if (!open) return;
    const first = tables?.[0]?.key || tables?.[0]?.table || "";
    setCollection((current) => current || first);
    setQuery("");
    setResults([]);
    setStatus("Choose a collection and search existing nodes.");
  }, [open, tables?.length]);

  async function runSearch() {
    if (!collection) return;
    setBusy(true);
    setStatus("Searching…");
    try {
      const items = await onSearch(collection, query);
      setResults(items);
      setStatus(`${items.length} node(s) found.`);
    } catch (error) {
      setResults([]);
      setStatus(`Error · ${String(error?.message || error)}`);
    } finally {
      setBusy(false);
    }
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent size="wide" className="border-border bg-card text-foreground">
        <DialogHeader className="px-6 pt-6">
          <DialogTitle>Search Node</DialogTitle>
          <p className="text-sm text-muted-foreground">Select an existing node for the relation target.</p>
          <p className={cx("text-xs", status.startsWith("Error") ? "text-danger" : "text-muted-foreground")}>{status}</p>
        </DialogHeader>
        <div className="flex flex-col gap-3 px-6 py-4">
          <div className="grid gap-3 md:grid-cols-[12rem_minmax(0,1fr)_auto]">
            <Select value={collection} onChange={(event) => setCollection(event?.target?.value || "")} disabled={busy}>
              {(tables || []).map((table) => (
                <SelectOption key={table.key || table.table} value={table.key || table.table} label={table.key || table.table} />
              ))}
            </Select>
            <Input value={query} onInput={(event) => setQuery(event?.target?.value || "")} placeholder="Search by _key, title, name, slug…" disabled={busy} />
            <Button type="button" size="sm" disabled={busy || !collection} onClick={runSearch}>Search</Button>
          </div>
          <div className="max-h-96 overflow-auto rounded-md border border-border/70">
            {results.length ? (
              <StudioTable>
                <StudioThead>
                  <tr>
                    <StudioTh>Node</StudioTh>
                    <StudioTh>Label</StudioTh>
                    <StudioTh></StudioTh>
                  </tr>
                </StudioThead>
                <tbody>
                  {results.map((item) => (
                    <tr key={item.slug}>
                      <StudioTd>{item.slug}</StudioTd>
                      <StudioTd>{item.label}</StudioTd>
                      <StudioTd>
                        <Button type="button" variant="outline" size="sm" onClick={() => { onSelect(item.slug); onOpenChange(false); }}>
                          Use
                        </Button>
                      </StudioTd>
                    </tr>
                  ))}
                </tbody>
              </StudioTable>
            ) : (
              <p className="px-3 py-6 text-center text-sm text-muted-foreground">No nodes loaded yet.</p>
            )}
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}

/**
 * An edge with no edge table behind it: written by an API or an import, shown
 * here, and changed only where it was written.
 */
function LooseTag() {
  return (
    <span
      title="A loose edge has no edge table: it is shown, not edited, here."
      className="ml-2 rounded border border-border px-1.5 py-px align-middle text-[10px] font-normal uppercase tracking-[0.08em] text-muted-foreground"
    >
      loose
    </span>
  );
}

export function RelationStatsList({ title, items, emptyText, peerKey }) {
  return (
    <div className="rounded-lg border border-border/80 bg-accent/10 p-3">
      <div className="mb-3 flex items-center justify-between gap-3">
        <p className="text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">{title}</p>
        <span className="text-xs text-muted-foreground">{items?.length || 0}</span>
      </div>
      {items?.length ? (
        <div className="space-y-2">
          {items.map((item, index) => (
            <div key={`${title}-${item.type}-${item.from}-${item.to}-${index}`} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 rounded-md border border-border/70 bg-popover px-3 py-2">
              <div className="min-w-0">
                <p className="truncate text-sm font-medium text-foreground">
                  {item.type}
                  {item.loose ? <LooseTag /> : null}
                </p>
                <p className="truncate text-xs text-muted-foreground">{peerKey === "to" ? `to ${item.to}` : `from ${item.from}`}</p>
                {item.countError ? (
                  <p className="mt-1 text-[11px] text-amber-500">Count unavailable</p>
                ) : null}
              </div>
              <div className="text-right">
                <p className="font-mono text-sm font-semibold tabular-nums text-foreground">
                  {item.count === null || item.count === undefined ? "n/a" : Number(item.count).toLocaleString()}
                </p>
                <p className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground">edges</p>
              </div>
            </div>
          ))}
        </div>
      ) : (
        <p className="text-sm text-muted-foreground">{emptyText}</p>
      )}
    </div>
  );
}

export function RowRelationList({ title, items, emptyText, onDelete }) {
  return (
    <div className="rounded-lg border border-border/80 bg-accent/10 p-3">
      <div className="mb-3 flex items-center justify-between gap-3">
        <p className="text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">{title}</p>
        <span className="text-xs text-muted-foreground">{items?.length || 0}</span>
      </div>
      {items?.length ? (
        <div className="space-y-2">
          {items.map((entry, index) => (
            <div key={`${title}-${entry.type}-${entry.otherSlug}-${index}`} className="rounded-md border border-border/70 bg-popover px-3 py-2">
              <div className="flex items-start justify-between gap-3">
                <div className="min-w-0">
                  <p className="text-sm font-medium text-foreground">
                    {entry.type}
                    {entry.loose ? <LooseTag /> : null}
                  </p>
                  <p className="truncate text-xs text-muted-foreground">{entry.otherLabel}</p>
                  <p className="truncate text-[11px] text-muted-foreground">{entry.otherSlug}</p>
                </div>
                {entry.loose ? null : (
                  <Button type="button" variant="ghost" size="sm" onClick={() => onDelete(entry)}>
                    Delete
                  </Button>
                )}
              </div>
            </div>
          ))}
        </div>
      ) : (
        <p className="text-sm text-muted-foreground">{emptyText}</p>
      )}
    </div>
  );
}