zebflow 0.10.11

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { geoLabel, geoViewState } from "@/components/db/geo-shape";
import { useState, useEffect, useRef, cx } from "zeb/react";
import {
  prettyValue,
  isGeoJsonGeometry,
  isGeoJsonPoint,
  formatGeoValue,
  stringifyCell,
  rawCellValue,
  isVectorColumn,
  isGeoColumn,
  formatVectorPreview,
  shouldCompactVectorCell,
  displayCellText,
  cellTitleText,
  defaultColumnWidth,
  autoSizeColumns,
} from "@/components/db/cell-format";

/**
 * The one data grid for every database engine.
 *
 * Resizable and sortable columns, optional inline editing, and cell
 * inspection. Engine differences arrive as props (which columns are
 * geometry, which are vectors) rather than as branches on a driver name.
 */
// No default here on purpose: `_key` is sekejap's column, not every engine's.
// The one caller always passes `caps.rowIdentity`; a fallback would let a
// driver that forgot to declare it look like it works instead of visibly
// failing to select a row.
export default function ResizableDataGrid({ columns, rows, columnMeta, identityColumn, selectedRowKey, onRowSelect, onCellInspect, mapRowToObject, editingCell, pendingEdits, onEditingCellChange, onCellEdit, vectorFields, geoFields, onGeoPick }) {
  const [colWidths, setColWidths] = useState({});
  const [sortCol, setSortCol] = useState(null);
  const [sortDir, setSortDir] = useState("asc");
  const dragRef = useRef(null);
  const editInputRef = useRef(null);

  // Auto-size widths when columns change
  useEffect(() => {
    setColWidths(autoSizeColumns(columns, rows, vectorFields));
  }, [columns.join(","), rows.length, (vectorFields || []).join(",")]);

  // Reset sort when columns change
  useEffect(() => { setSortCol(null); }, [columns.join(",")]);

  useEffect(() => {
    if (editingCell && editInputRef.current) {
      editInputRef.current.focus();
      editInputRef.current.select();
    }
  }, [editingCell?.rowIndex, editingCell?.colIndex]);

  function onResizeStart(e, colIndex) {
    e.preventDefault();
    e.stopPropagation();
    const col = columns[colIndex];
    const startX = e.clientX;
    const startW = colWidths[col] || defaultColumnWidth(col);

    function onMove(ev) {
      const delta = ev.clientX - startX;
      const nextW = Math.max(48, startW + delta);
      setColWidths((prev) => ({ ...prev, [col]: nextW }));
    }
    function onUp() {
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
      dragRef.current = null;
    }
    dragRef.current = col;
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  }

  function onHeaderClick(colIndex) {
    if (dragRef.current) return;
    const col = columns[colIndex];
    if (sortCol === col) {
      setSortDir((prev) => prev === "asc" ? "desc" : "asc");
    } else {
      setSortCol(col);
      setSortDir("asc");
    }
  }

  // Sort rows
  const sortedRows = (() => {
    if (sortCol === null) return rows;
    const colIdx = columns.indexOf(sortCol);
    if (colIdx < 0) return rows;
    const copy = [...rows];
    copy.sort((a, b) => {
      const av = Array.isArray(a) ? a[colIdx] : undefined;
      const bv = Array.isArray(b) ? b[colIdx] : undefined;
      const as = displayCellText(av, sortCol, vectorFields);
      const bs = displayCellText(bv, sortCol, vectorFields);
      // Try numeric comparison first
      const an = Number(as);
      const bn = Number(bs);
      if (!isNaN(an) && !isNaN(bn)) {
        return sortDir === "asc" ? an - bn : bn - an;
      }
      const cmp = as.localeCompare(bs);
      return sortDir === "asc" ? cmp : -cmp;
    });
    return copy;
  })();

  if (!columns.length) return null;

  return (
    <table className="w-full border-collapse project-table" style={{ width: "max-content", minWidth: "100%" }}>
      <thead className="bg-muted">
        <tr>
          {columns.map((col, index) => {
            const isSorted = sortCol === col;
            // What the engine says about this column, so the header can state
            // its type and what a foreign key points at. Without it a rejected
            // value gives no clue what a valid one would be.
            const meta = (columnMeta || {})[col] || {};
            const ref = meta.fk
              ? `${meta.fk.schema ? `${meta.fk.schema}.` : ""}${meta.fk.table}.${meta.fk.column}`
              : "";
            const headerTitle = [
              col,
              meta.full_type || meta.type || "",
              meta.pk ? "primary key" : "",
              ref ? `references ${ref}` : "",
              meta.nullable === false ? "not null" : "",
            ]
              .filter(Boolean)
              .join(" · ");
            return (
              <th
                key={`${col}-${index}`}
                title={headerTitle}
                className="relative px-[0.65rem] py-[0.4rem] border-b border-border text-left text-[0.68rem] font-mono uppercase tracking-[0.12em] text-muted-foreground select-none cursor-pointer hover:text-foreground"
                style={{ width: colWidths[col] || defaultColumnWidth(col), minWidth: 48, maxWidth: 600 }}
                onClick={() => onHeaderClick(index)}
              >
                <span className="flex items-center gap-1 overflow-hidden whitespace-nowrap">
                  <span className="overflow-hidden text-ellipsis">{col}</span>
                  {/* A column that points at another table says so, because a
                      value it will accept is not guessable otherwise. */}
                  {ref ? (
                    <span className="shrink-0 text-[0.6rem] normal-case tracking-normal text-muted-foreground" title={`references ${ref}`}>
                      {"\u2192"}
                    </span>
                  ) : null}
                  {isSorted && (
                    <svg viewBox="0 0 10 10" fill="currentColor" className="w-2.5 h-2.5 shrink-0 opacity-70">
                      {sortDir === "asc"
                        ? <path d="M5 2L9 8H1Z" />
                        : <path d="M5 8L1 2H9Z" />
                      }
                    </svg>
                  )}
                </span>
                <div
                  className="absolute top-0 right-0 w-[5px] h-full cursor-col-resize group"
                  onMouseDown={(ev) => onResizeStart(ev, index)}
                >
                  <div className="absolute top-1 bottom-1 right-[2px] w-[1px] bg-border opacity-0 hover:opacity-100 transition-opacity" />
                </div>
              </th>
            );
          })}
        </tr>
      </thead>
      <tbody>
        {sortedRows.map((row, rowIndex) => {
          const record = mapRowToObject(columns, row);
          // The column that identifies a row, which differs by engine: sekejap
          // answers `_key`, a SQL engine its primary key. Without this a row on
          // a SQL engine has no identity, so it cannot be selected, edited or
          // deleted.
          const rowKey = String(record?.[identityColumn] ?? "");
          const isSelected = selectedRowKey && rowKey === selectedRowKey;
          const rowPending = pendingEdits?.[rowKey] || {};
          return (
            <tr key={`row-${rowIndex}`} className={isSelected ? "is-row-selected" : ""}>
              {(Array.isArray(row) ? row : []).map((cell, cellIndex) => {
                const colName = columns[cellIndex] || `column_${cellIndex + 1}`;
                const isSystemCol = colName.startsWith("_");
                const isEditing = editingCell && editingCell.rowIndex === rowIndex && editingCell.colIndex === cellIndex;
                const hasPending = colName in rowPending;
                const displayValue = hasPending ? rowPending[colName] : cell;
                const isVectorCol = isVectorColumn(vectorFields, colName);
                const isGeoCol = isGeoColumn(geoFields, colName);
                const compactVector = shouldCompactVectorCell(displayValue, colName, vectorFields);
                return (
                  <td
                    key={`cell-${rowIndex}-${cellIndex}`}
                    className={`px-[0.65rem] border-b border-border text-left text-[0.78rem] text-foreground cursor-pointer whitespace-nowrap overflow-hidden text-ellipsis ${isEditing ? "p-0" : "py-[0.35rem]"} ${hasPending ? "bg-amber-500/10" : ""}`}
                    style={{ maxWidth: colWidths[colName] || defaultColumnWidth(colName) }}
                    title={isEditing ? undefined : cellTitleText(displayValue, colName, vectorFields)}
                    onClick={() => {
                      onRowSelect(rowKey, record);
                      onCellInspect(colName, rowIndex, cell);
                    }}
                    onDoubleClick={() => {
                      if (!isSystemCol && !isVectorCol && rowKey && onEditingCellChange) {
                        onEditingCellChange({ rowIndex, colIndex: cellIndex, rowKey, colName });
                      }
                    }}
                  >
                    {isEditing ? (
                      <input
                        ref={editInputRef}
                        type="text"
                        className="w-full border-0 bg-popover px-[0.65rem] py-[0.35rem] text-[0.78rem] text-foreground outline-none ring-1 ring-inset ring-blue-500"
                        defaultValue={rawCellValue(displayValue)}
                        onBlur={(e) => {
                          const val = e.target.value;
                          if (val !== rawCellValue(cell)) {
                            onCellEdit(rowKey, colName, val);
                          }
                          onEditingCellChange(null);
                        }}
                        onKeyDown={(e) => {
                          if (e.key === "Enter") e.target.blur();
                          if (e.key === "Escape") {
                            e.target.value = rawCellValue(cell);
                            onEditingCellChange(null);
                          }
                          if (e.key === "Tab") {
                            e.preventDefault();
                            const val = e.target.value;
                            if (val !== rawCellValue(cell)) {
                              onCellEdit(rowKey, colName, val);
                            }
                            let nextCol = cellIndex + (e.shiftKey ? -1 : 1);
                            while (nextCol >= 0 && nextCol < columns.length && columns[nextCol]?.startsWith("_")) {
                              nextCol += e.shiftKey ? -1 : 1;
                            }
                            if (nextCol >= 0 && nextCol < columns.length) {
                              onEditingCellChange({ rowIndex, colIndex: nextCol, rowKey, colName: columns[nextCol] });
                            } else {
                              onEditingCellChange(null);
                            }
                          }
                        }}
                      />
                    ) : (
                      <span className={cx("inline-flex max-w-full items-center gap-1", isGeoCol ? "w-full" : "")}>
                        <span className="min-w-0 overflow-hidden text-ellipsis">
                          {isGeoJsonPoint(displayValue) ? (
                            <span className="inline-flex items-center gap-1">
                              <svg viewBox="0 0 12 12" fill="none" className="w-3 h-3 shrink-0 opacity-50">
                                <path d="M6 1C4.067 1 2.5 2.567 2.5 4.5C2.5 7.25 6 11 6 11s3.5-3.75 3.5-6.5C9.5 2.567 7.933 1 6 1Zm0 4.75a1.25 1.25 0 110-2.5 1.25 1.25 0 010 2.5Z" fill="currentColor"/>
                              </svg>
                              <span>{formatGeoValue(displayValue)}</span>
                            </span>
                          ) : compactVector ? (
                            <span className="font-mono text-[0.73rem] text-[#ebbe7d]">
                              {formatVectorPreview(displayValue)}
                            </span>
                          ) : (displayValue == null || displayValue === "") && isVectorCol ? (
                            <span className="inline-flex items-center gap-1 text-[#e9904e]">
                              <svg viewBox="0 0 12 12" fill="none" className="w-3 h-3 shrink-0">
                                <circle cx="3" cy="6" r="1.5" fill="currentColor" opacity="0.6"/>
                                <circle cx="6" cy="3" r="1.5" fill="currentColor" opacity="0.8"/>
                                <circle cx="9" cy="6" r="1.5" fill="currentColor" opacity="0.6"/>
                                <circle cx="6" cy="9" r="1.5" fill="currentColor" opacity="0.4"/>
                              </svg>
                              <span className="text-[11px]">vector</span>
                            </span>
                          ) : (displayValue == null || displayValue === "") && isGeoCol ? (
                            <span className="text-[11px] text-[#e9904e]">geo</span>
                          ) : stringifyCell(displayValue)}
                        </span>
                        {isGeoCol && !isSystemCol ? (
                          <button
                            type="button"
                            title="Pick geometry on map"
                            className="ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border border-border/70 bg-popover text-muted-foreground hover:border-[#f6863c] hover:text-[#f6863c]"
                            onClick={(event) => {
                              event.preventDefault();
                              event.stopPropagation();
                              onRowSelect(rowKey, record);
                              onCellInspect(colName, rowIndex, cell);
                              onGeoPick?.({ rowKey, colName, value: displayValue, rowIndex, colIndex: cellIndex, record });
                            }}
                          >
                            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-3.5 w-3.5">
                              <path d="M8 1.75c-2.07 0-3.75 1.68-3.75 3.75 0 2.95 3.75 8.75 3.75 8.75s3.75-5.8 3.75-8.75c0-2.07-1.68-3.75-3.75-3.75Z"/>
                              <circle cx="8" cy="5.5" r="1.25"/>
                            </svg>
                          </button>
                        ) : null}
                      </span>
                    )}
                  </td>
                );
              })}
            </tr>
          );
        })}
      </tbody>
      <tfoot>
        <tr>
          <td
            colSpan={columns.length}
            className="px-[0.65rem] py-[0.3rem] text-[0.68rem] text-muted-foreground border-t border-border bg-muted/50"
          >
            {sortedRows.length} rows{sortCol ? ` · sorted by ${sortCol} ${sortDir}` : ""}
          </td>
        </tr>
      </tfoot>
    </table>
  );
}