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;
}