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