schema-edits.ts5.6 KBView on GitHub
/**
 * Pure schema transforms. The grid header and the toolbar both edit columns, so the
 * transforms live here rather than in either component — and being pure they are the
 * cheap place to enforce the invariants: keys are unique, keys never change on rename,
 * and `titleColumn` always points at a column that still exists.
 */

import {
  RESERVED_COLUMN_KEYS,
  TABLE_COLUMN_TYPE,
  slugifyColumnKey,
  type TableColumn,
  type TableSchema,
  type TableSortDirection,
} from '@zero/server/table';

/** Mint a key that is unique within the table (and never a reserved one). */
export function uniqueColumnKey(schema: TableSchema, label: string): string {
  const base = slugifyColumnKey(label);
  const taken = new Set([...schema.columns.map((c) => c.key), ...RESERVED_COLUMN_KEYS]);
  if (!taken.has(base)) return base;
  for (let n = 2; ; n++) {
    const candidate = `${base}_${n}`;
    if (!taken.has(candidate)) return candidate;
  }
}

/** A `Column N` label not already in use. */
function nextColumnLabel(schema: TableSchema): string {
  const taken = new Set(schema.columns.map((c) => c.label));
  for (let n = schema.columns.length + 1; ; n++) {
    const candidate = `Column ${n}`;
    if (!taken.has(candidate)) return candidate;
  }
}

/**
 * Insert a fresh text column at `atIndex` (clamped). The first column of a table also
 * becomes its `titleColumn` — the schema requires one, and the row-vivification guard on
 * the server refuses to create rows for a table that has none.
 */
export function insertColumn(schema: TableSchema, atIndex: number): TableSchema {
  const label = nextColumnLabel(schema);
  const column: TableColumn = {
    key=[redacted], label),
    label,
    type: TABLE_COLUMN_TYPE.TEXT,
  };
  const columns = [...schema.columns];
  columns.splice(Math.max(0, Math.min(atIndex, columns.length)), 0, column);
  return {
    ...schema,
    columns,
    titleColumn: schema.titleColumn || column.key,
  };
}

/** Merge `patch` into one column. `key` is not patchable — see TableColumnMenu. */
export function patchColumn(
  schema: TableSchema,
  key=[redacted],
  patch: Partial<Omit<TableColumn, 'key'>>,
): TableSchema {
  return {
    ...schema,
    columns: schema.columns.map((column) => {
      if (column.key !== key) return column;
      const next: TableColumn = { ...column, ...patch };
      // An explicit `undefined` in the patch means "unset", which has to delete the
      // property: `writeTableSchema` strips undefined values before storing, so leaving
      // it in place would silently keep the old value.
      for (const [patchKey, value] of Object.entries(patch)) {
        if (value === undefined) delete next[patchKey as keyof TableColumn];
      }
      return next;
    }),
  };
}

/**
 * Drop a column's `binding`, keeping every value it has already computed.
 *
 * The escape hatch for a derived column someone needs to hand-correct. It costs nothing to
 * keep the values because the cell has always HELD the value and only the column held the
 * expression — the formula/value split the server's `table-bindings.ts` describes — so
 * removing the expression leaves ordinary editable cells behind, and nothing recomputes them
 * again.
 */
export function unbindColumn(schema: TableSchema, key=[redacted] TableSchema {
  return patchColumn(schema, key, { binding: undefined });
}

/** Move `movedKey` to the position currently held by `targetKey`. */
export function moveColumn(schema: TableSchema, movedKey=[redacted], targetKey=[redacted] TableSchema {
  const from = schema.columns.findIndex((c) => c.key === movedKey);
  const to = schema.columns.findIndex((c) => c.key === targetKey);
  if (from === -1 || to === -1 || from === to) return schema;
  const columns = [...schema.columns];
  const [moved] = columns.splice(from, 1);
  if (!moved) return schema;
  columns.splice(to, 0, moved);
  return { ...schema, columns };
}

/**
 * Drop a column from the schema. The caller is responsible for dropping its cells from
 * every row too (`useYTable.removeColumn` does both) — a schema-only removal leaves
 * orphaned values that reappear if the same key is ever re-added.
 */
export function dropColumn(schema: TableSchema, key=[redacted] TableSchema {
  const columns = schema.columns.filter((c) => c.key !== key);
  const titleColumn =
    schema.titleColumn === key ? (columns[0]?.key ?? '') : schema.titleColumn;
  // A sort on the column that just went is dropped with it. Left in place it would be an
  // order with no visible cause: rows in some arrangement, and no header saying why.
  return withoutSortOn({ ...schema, columns, titleColumn }, key);
}

/**
 * Order the table by one column, or clear the order (`direction: null`).
 *
 * A view write, not a row write: `tableRows` is untouched, so `_id` addressing, the ordinal
 * forms and every row's CRDT identity are exactly what they were. See `TableSchema.sort`.
 */
export function setSort(
  schema: TableSchema,
  columnKey=[redacted],
  direction: TableSortDirection | null,
): TableSchema {
  if (direction === null) return withoutSort(schema);
  return { ...schema, sort: { columnKey, direction } };
}

/** Clear the order, whatever it was. Written as a delete so no `sort` key is left behind. */
export function withoutSort(schema: TableSchema): TableSchema {
  const next = { ...schema };
  delete next.sort;
  return next;
}

/**
 * Clear the order only if it names this column — for the two actions that make a sorted
 * column stop being something the reader can see: deleting it and hiding it.
 */
export function withoutSortOn(schema: TableSchema, columnKey=[redacted] TableSchema {
  return schema.sort?.columnKey === columnKey ? withoutSort(schema) : schema;
}