zebflow 0.9.0

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 DialogFooter from "@/components/ui/dialog-footer";
import Button from "@/components/ui/button";
import Field from "@/components/ui/field";
import Input from "@/components/ui/input";
import { Select, SelectOption } from "@/components/ui/select";
import { StudioTable, StudioTd } from "@/components/ui/studio-data-table";

/**
 * Free 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.
 */

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 normalizeRelationType(value) {
  return String(value || "")
    .trim()
    .replace(/[^A-Za-z0-9_]+/g, "_")
    .replace(/^_+|_+$/g, "");
}

export function relationSlugParts(slug) {
  const text = String(slug || "").trim();
  const [collection, key, ...rest] = text.split("/");
  if (!collection || !key || rest.length) return null;
  return { collection, key };
}

export function uniqueRelationDefs(defs) {
  const seen = new Set();
  return (defs || [])
    .map((item) => ({
      from: String(item?.from || "").trim(),
      to: String(item?.to || "").trim(),
      type: String(item?.type || "").trim(),
    }))
    .filter((item) => item.from && item.to && item.type)
    .filter((item) => {
      const key = `${item.from}:${item.type}:${item.to}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
}

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 RelationDialog({
  open,
  onOpenChange,
  busy,
  status,
  direction,
  setDirection,
  relationType,
  setRelationType,
  relatedNodeSlug,
  setRelatedNodeSlug,
  currentNodeSlug,
  relationTypeOptions,
  relatedSlugWarning,
  onOpenTargetSearch,
  onSubmit,
}) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-xl border-border bg-card text-foreground">
        <DialogHeader className="px-6 pt-6">
          <DialogTitle>Create Relation</DialogTitle>
          <p className="text-sm text-muted-foreground">
            Link the current node to another node in the Sekejap store.
          </p>
          <p className={cx("text-xs", status.startsWith("Error") ? "text-danger" : status.startsWith("Created") ? "text-success" : "text-muted-foreground")}>
            {status}
          </p>
        </DialogHeader>

        <form onSubmit={onSubmit} className="flex flex-col gap-4 px-6 py-4">
          <Field label="Current Node">
            <Input value={currentNodeSlug} disabled />
          </Field>

          <div className="grid gap-3 md:grid-cols-2">
            <Field label="Direction">
              <Select value={direction} onChange={(event) => setDirection(event?.target?.value || "outgoing")} disabled={busy}>
                <SelectOption value="outgoing" label="Current -> related" />
                <SelectOption value="incoming" label="Related -> current" />
              </Select>
            </Field>
            <Field label="Relation Type">
              <div className="flex flex-col gap-2">
                {relationTypeOptions?.length ? (
                  <Select value={relationTypeOptions.includes(relationType) ? relationType : ""} onChange={(event) => setRelationType(event?.target?.value || "")} disabled={busy}>
                    <SelectOption value="" label="New or custom type" />
                    {relationTypeOptions.map((type) => (
                      <SelectOption key={type} value={type} label={type} />
                    ))}
                  </Select>
                ) : null}
                <Input
                  value={relationType}
                  onInput={(event) => setRelationType(event?.target?.value || "")}
                  placeholder="references"
                  required
                  disabled={busy}
                />
              </div>
            </Field>
          </div>

          <Field label={direction === "outgoing" ? "Target Node Slug" : "Source Node Slug"}>
            <div className="flex gap-2">
              <Input
                value={relatedNodeSlug}
                onInput={(event) => setRelatedNodeSlug(event?.target?.value || "")}
                placeholder="people/alice"
                required
                disabled={busy}
              />
              <Button type="button" variant="outline" size="sm" disabled={busy} onClick={onOpenTargetSearch}>
                Search
              </Button>
            </div>
            {relatedSlugWarning ? (
              <p className="mt-1 text-xs text-amber-500">{relatedSlugWarning}</p>
            ) : null}
          </Field>

          <p className="text-xs text-muted-foreground">
            Use the Sekejap node slug format: <span className="font-mono">collection/key</span>.
          </p>

          <DialogFooter>
            <Button type="button" variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={busy}>
              Cancel
            </Button>
            <Button type="submit" size="sm" disabled={busy}>
              {busy ? "Creating…" : "Create"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

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]?.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.table} value={table.table} label={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>
  );
}

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}</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}</p>
                  <p className="truncate text-xs text-muted-foreground">{entry.otherLabel}</p>
                  <p className="truncate text-[11px] text-muted-foreground">{entry.otherSlug}</p>
                </div>
                <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>
  );
}