zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { useState, cx } from "zeb/react";
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 { 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 { sqlStringLiteral } from "@/components/db/table-data";
import { RelationTargetSearchDialog } from "@/components/db/relations-graph";
import {
  edgeInsertSql,
  edgePropertyColumns,
  edgeTablesTouching,
  labelColumn,
  tableByKey,
} from "@/components/db/edge-tables";

/**
 * Drawing a new edge from the selected row, through an edge table.
 *
 * An edge table fixes both ends: which table the edge starts in and which it
 * reaches. So the form asks only which edge table, which row on the other
 * side, and the edge's own properties. With no edge table touching this
 * table there is nothing to offer, and the trigger is not shown. What it will
 * not decide is whether the input was acceptable — a missing row is reported
 * through `onInvalidInput`, whose dialog belongs to the page.
 */
export default function RelationCreatePanel({ runDbQuery, tables, current, onCreated, onInvalidInput }) {
  const { table, record } = current;
  const edgeTables = edgeTablesTouching(tables, table?.graphName);

  const [open, setOpen] = useState(false);
  const [searchOpen, setSearchOpen] = useState(false);
  const [busy, setBusy] = useState(false);
  const [status, setStatus] = useState("");
  const [edgeKey, setEdgeKey] = useState("");
  const [otherKey, setOtherKey] = useState("");
  const [properties, setProperties] = useState({});

  if (!edgeTables.length || !table) return null;

  const edgeTable = edgeTables.find((item) => item.key === edgeKey) || edgeTables[0];
  const edge = edgeTable.edge;
  const currentIsSource = edge.source_table === table.graphName;
  const otherTable = tableByKey(tables, currentIsSource ? edge.destination_table : edge.source_table);
  const otherTableKey = currentIsSource ? edge.destination_table : edge.source_table;
  const currentKey = String(record?._key || "").trim();

  function openDialog() {
    setEdgeKey(edgeTables[0].key);
    setOtherKey("");
    setProperties({});
    setStatus("");
    setOpen(true);
  }

  async function submit(event) {
    event?.preventDefault?.();
    const other = otherKey.trim();
    if (!currentKey) {
      setStatus("Error · Select a row first.");
      return;
    }
    if (!other) {
      setStatus(`Error · Choose the ${otherTableKey} row this edge reaches.`);
      return;
    }
    setBusy(true);
    setStatus("Creating edge…");
    try {
      const exists = await runDbQuery(
        `SELECT _key FROM ${otherTableKey} WHERE _key = '${sqlStringLiteral(other)}'`,
        { readOnly: true, tableName: otherTableKey, limit: 1 },
      );
      if (!exists.rows.length) {
        onInvalidInput({
          title: "Row Not Found",
          message: `${otherTableKey} has no row '${other}'. Search and pick an existing row.`,
          example: `${otherTableKey}/existing_key`,
        });
        setStatus(`Error · ${otherTableKey}/${other} not found.`);
        return;
      }
      const [sourceKey, destinationKey] = currentIsSource ? [currentKey, other] : [other, currentKey];
      await runDbQuery(edgeInsertSql(edgeTable, sourceKey, destinationKey, properties), {
        readOnly: false,
        tableName: edgeTable.key,
        limit: 0,
      });
      setOpen(false);
      await onCreated();
    } catch (error) {
      setStatus(`Error · ${String(error?.message || error)}`);
    } finally {
      setBusy(false);
    }
  }

  /** Rows of the other table whose label or key contains the text. */
  async function searchTargets(collection, query) {
    const target = tableByKey(tables, collection);
    const label = labelColumn(target);
    const needle = String(query || "").trim();
    const select = label ? `_key, ${label}` : "_key";
    // One ILIKE, on the label or else the key: sekejap answers a LIKE from
    // the row, so it cannot be one side of an OR.
    const where = needle ? ` WHERE ${label || "_key"} ILIKE '%${sqlStringLiteral(needle)}%'` : "";
    const result = await runDbQuery(`SELECT ${select} FROM ${collection}${where} LIMIT 50`, {
      readOnly: true,
      tableName: collection,
      limit: 50,
    });
    return (result.objects || []).map((row) => ({
      slug: String(row._key || ""),
      label: String((label && row[label]) || row._key || ""),
    }));
  }

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

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent className="max-w-xl border-border bg-card text-foreground">
          <DialogHeader className="px-6 pt-6">
            <DialogTitle>New Relation</DialogTitle>
            <p className="text-sm text-muted-foreground">
              {currentIsSource
                ? `From this ${table.table} row to a ${otherTableKey} row, through ${edgeTable.table}.`
                : `From a ${otherTableKey} row to this ${table.table} row, through ${edgeTable.table}.`}
            </p>
            {status ? (
              <p className={cx("text-xs", status.startsWith("Error") ? "text-danger" : "text-muted-foreground")}>{status}</p>
            ) : null}
          </DialogHeader>

          <form onSubmit={submit} className="flex flex-col gap-4 px-6 py-4">
            <Field label="Edge table">
              <Select value={edgeTable.key} onChange={(event) => { setEdgeKey(event?.target?.value || ""); setOtherKey(""); setProperties({}); }} disabled={busy}>
                {edgeTables.map((item) => (
                  <SelectOption
                    key={item.key}
                    value={item.key}
                    label={`${item.table} · ${item.edge.source_table} → ${item.edge.destination_table}`}
                  />
                ))}
              </Select>
            </Field>

            <Field label={`${otherTableKey} row`}>
              <div className="flex gap-2">
                <Input value={otherKey} onInput={(event) => setOtherKey(event?.target?.value || "")} placeholder="_key" required disabled={busy} />
                <Button type="button" variant="outline" size="sm" disabled={busy || !otherTable} onClick={() => setSearchOpen(true)}>
                  Search
                </Button>
              </div>
            </Field>

            {edgePropertyColumns(edgeTable).map((attr) => (
              <Field key={attr.name} label={`${attr.name}${attr.declared ? ` · ${attr.declared}` : ""}`}>
                <Input
                  value={properties[attr.name] || ""}
                  onInput={(event) => setProperties({ ...properties, [attr.name]: event?.target?.value || "" })}
                  placeholder={attr.default ? `default ${attr.default}` : ""}
                  disabled={busy}
                />
              </Field>
            ))}

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

      <RelationTargetSearchDialog
        open={searchOpen}
        onOpenChange={setSearchOpen}
        tables={otherTable ? [otherTable] : []}
        onSearch={searchTargets}
        onSelect={setOtherKey}
      />
    </>
  );
}