zebflow 0.9.0

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { useState } from "zeb/react";
import Button from "@/components/ui/button";
import { sqlStringLiteral } from "@/components/db/table-data";
import {
  RelationDialog,
  RelationTargetSearchDialog,
  normalizeRelationType,
  relationNodeLabel,
  relationNodeSlug,
  relationSlugParts,
} from "@/components/db/relations-graph";

/**
 * Drawing a new edge from the selected row.
 *
 * Owns the whole form and its own trigger. What it will not decide is whether
 * the input was acceptable to the reader — a bad slug or a missing target is
 * reported through `onInvalidInput`, because the dialog that explains it
 * belongs to the page and is shown over everything.
 */
export default function RelationCreatePanel({
  runDbQuery,
  tables,
  current,
  typeOptions,
  onCreated,
  onInvalidInput,
}) {
  const { table, record } = current;

  const [open, setOpen] = useState(false);
  const [searchOpen, setSearchOpen] = useState(false);
  const [busy, setBusy] = useState(false);
  const [status, setStatus] = useState(
    "Choose the direction, relation type, and related node slug.",
  );
  const [direction, setDirection] = useState("outgoing");
  const [type, setType] = useState("");
  const [relatedSlug, setRelatedSlug] = useState("");

function openDialog() {
  setStatus("Choose the direction, relation type, and related node slug.");
  setDirection("outgoing");
  setType("");
  setRelatedSlug("");
  setOpen(true);
}

async function submit(event) {
  event?.preventDefault?.();
  const currentNodeSlug = relationNodeSlug(record, table?.table || "");
  const currentKey = String(record?._key || "").trim();
  const normalizedType = normalizeRelationType(type);
  const otherSlug = String(relatedSlug || "").trim();
  const otherParts = relationSlugParts(otherSlug);

  if (!table?.table || !currentNodeSlug || !currentKey) {
    setStatus("Error · Select a concrete row first.");
    return;
  }
  if (!normalizedType) {
    setStatus("Error · Relation type is required.");
    return;
  }
  if (!otherParts) {
    onInvalidInput({
      title: "Invalid Node Slug",
      message: "Related node slug must use collection/key format.",
      example: "people/alice",
    });
    setStatus("Error · Related node slug must use collection/key.");
    return;
  }
  if (!tables.some((table) => table.table === otherParts.collection)) {
    onInvalidInput({
      title: "Unknown Collection",
      message: `Collection '${otherParts.collection}' does not exist in this Sekejap store. Choose an existing collection before creating the relation.`,
      example: `${table.table}/${currentKey}`,
    });
    setStatus(`Error · Unknown collection '${otherParts.collection}'.`);
    return;
  }

  const fromSlug = direction === "outgoing" ? currentNodeSlug : otherSlug;
  const toSlug = direction === "outgoing" ? otherSlug : currentNodeSlug;
  const sql = `INSERT ('${sqlStringLiteral(fromSlug)}')-[:${normalizedType}]->('${sqlStringLiteral(toSlug)}')`;

  setBusy(true);
  setStatus("Creating relation…");
  try {
    const exists = await runDbQuery(`SELECT _key FROM ${otherParts.collection} WHERE _key = '${sqlStringLiteral(otherParts.key)}'`, {
      readOnly: true,
      tableName: otherParts.collection,
      limit: 1,
    });
    if (!exists.rows.length) {
      onInvalidInput({
        title: "Target Node Not Found",
        message: `Node '${otherSlug}' does not exist. Search and select an existing node, or create the node first.`,
        example: `${otherParts.collection}/existing_key`,
      });
      setStatus(`Error · Node '${otherSlug}' not found.`);
      return;
    }
    await runDbQuery(sql, { readOnly: false, tableName: table.table, limit: 50 });
    setStatus(`Created · ${normalizedType}`);
    setOpen(false);
    await onCreated();
  } catch (error) {
    setStatus(`Error · ${String(error?.message || error)}`);
  } finally {
    setBusy(false);
  }
}

async function searchTargets(collection, query) {
  const q = String(query || "").trim();
  const table = (tables || []).find((item) => String(item?.table || "") === String(collection || ""));
  const candidateCols = [
    "_collection",
    "_key",
    "_id",
    ...(table?.attributes || [])
      .filter((attr) => {
        const kind = String(attr?.kind || "").toLowerCase();
        return kind !== "geo" && kind !== "vector";
      })
      .map((attr) => String(attr?.name || "").trim())
      .filter(Boolean),
  ];
  const seenCols = new Set();
  const columns = candidateCols.filter((col) => {
    if (!col || seenCols.has(col)) return false;
    seenCols.add(col);
    return true;
  });
  const result = await runDbQuery(
    `SELECT ${columns.join(", ")} FROM ${collection}`,
    { readOnly: true, tableName: collection, limit: 200 }
  );
  return result.objects.map((item) => ({
    slug: relationNodeSlug(item, collection),
    label: relationNodeLabel(item, collection),
    searchText: Object.values(item || {}).map((value) => {
      if (value === null || value === undefined) return "";
      if (typeof value === "object") {
        try { return JSON.stringify(value); } catch (_) { return ""; }
      }
      return String(value);
    }).join(" "),
  })).filter((item) => {
    if (!item.slug) return false;
    const needle = q.toLowerCase();
    return !needle || item.slug.toLowerCase().includes(needle) || item.label.toLowerCase().includes(needle) || item.searchText.toLowerCase().includes(needle);
  });
}

  const currentSlug = relationNodeSlug(record, table?.table || "");
  const relatedParts = relationSlugParts(String(relatedSlug || "").trim());
  const relatedWarning =
    relatedSlug.trim() && relatedParts && !tables.some((t) => t.table === relatedParts.collection)
      ? `Collection '${relatedParts.collection}' does not exist in this store.`
      : "";

  return (
    <>
      <Button type="button" variant="outline" size="sm" onClick={openDialog}>
        New Relation
      </Button>

      <RelationDialog
        open={open}
        onOpenChange={setOpen}
        busy={busy}
        status={status}
        direction={direction}
        setDirection={setDirection}
        relationType={type}
        setRelationType={setType}
        relatedNodeSlug={relatedSlug}
        setRelatedNodeSlug={setRelatedSlug}
        currentNodeSlug={currentSlug}
        relationTypeOptions={typeOptions}
        relatedSlugWarning={relatedWarning}
        onOpenTargetSearch={() => setSearchOpen(true)}
        onSubmit={submit}
      />

      <RelationTargetSearchDialog
        open={searchOpen}
        onOpenChange={setSearchOpen}
        tables={tables}
        onSearch={searchTargets}
        onSelect={setRelatedSlug}
      />
    </>
  );
}