Foundations

Changes to records

What was done to a list of records, as a value: told to its owner, applied, and turned about to undo it.

Markdown

An interface that edits many records at once — a grid, a list of items — does not own them: an application does, and so may a server, and so may other people editing at the same time. What the interface did is best said as a change: what was changed, where, from what and to what.

Best practices

Say what changed, not only what there now is. A change names the fields it altered with the value each took and the value each held, the records it added and where, the records it took away and from where. An owner with a hundred thousand records, or a store that is not a list at all, can apply that; it cannot afford to compare two copies of everything.

Leave untouched what was not changed. A record that a change does not touch is the same record after it, so that whatever else shows the data redraws only what changed.

Keep what is needed to take it back. A change that carries the values it replaced and the records it removed can be turned about: undo is the same change, reversed, and redo is the change again. Nothing has to be remembered besides.

Put back the very records. Undoing a removal returns the records that were removed, to the places they were in — not copies, and not at the end.

Make one action one change to undo. A paste that fills cells and adds records to hold the rest is one action; it is taken back whole.

Add a record only for something. Typing into the row that waits after the last record makes a record. Clearing it, or pasting nothing into it, does not.

Ignore what no longer applies. A change that names a record that is no longer there changes nothing there, rather than failing.

See also Undo and redo and Data grid.

Behaviour

Applying

A change to fields changes those fields, and leaves every other record the very one it was
const before = items(4);
const after = applyChange(before, {
  kind: "cells",
  cells: [{ row: 2, key: "name", value: "new", previous: "n2" }],
});
expect(after[2]).toEqual({ id: 2, name: "new" });
expect([after[0], after[1], after[3]]).toEqual([before[0], before[1], before[3]]);
expect(after[0]).toBe(before[0]);
expect(before[2]!.name).toBe("n2");
An insertion puts records in at its place, and the rest follow
const after = applyChange(items(3), { kind: "insert", at: 1, rows: [{ id: 10 }, { id: 11 }] });
expect(after.map((item) => item.id)).toEqual([0, 10, 11, 1, 2]);
A removal takes out each run, each at its place in the list as it was
const before = items(8);
const after = applyChange(before, { kind: "remove", runs: runsOf(before, [6, 1, 2, 4]) });
expect(after.map((item) => item.id)).toEqual([0, 3, 5, 7]);
A change that names a record that is not there changes nothing there
const before = items(2);
expect(
  applyChange(before, { kind: "cells", cells: [{ row: 5, key: "name", value: "x", previous: null }] }),
).toEqual(before);
expect(
  applyChange(before, { kind: "insert", at: 99, rows: [{ id: 9 }] }).map((item) => item.id),
).toEqual([0, 1, 9]);

Undoing

A change, then what undoes it, gives back the list as it was — the same records, in the same places
fc.assert(
  fc.property(
    fc.nat(12).chain((count) => fc.tuple(fc.constant(count), changeFor(count))),
    ([count, generated]) => {
      const before = items(count);
      const change = of(before, generated);
      let after = applyChange(before, change);
      for (const undo of invertChange(change)) after = applyChange(after, undo);
      // Records that were only moved about are the very same; changed fields are equal again.
      return (
        after.length === before.length &&
        after.every((item, index) =>
          change.kind === "cells"
            ? item.id === index && item.name === `n${index}`
            : item === before[index],
        )
      );
    },
  ),
);
Undoing what undid a change does the change again
fc.assert(
  fc.property(
    fc.nat(12).chain((count) => fc.tuple(fc.constant(count), changeFor(count))),
    ([count, change]) => {
      const before = items(count);
      const done = applyChange(before, change);
      let again = done;
      const undo = invertChange(change);
      for (const step of undo) again = applyChange(again, step);
      for (const step of undo.toReversed().flatMap(invertChange)) again = applyChange(again, step);
      return JSON.stringify(again) === JSON.stringify(done);
    },
  ),
);

Runs

Places side by side are one run; the places may be given in any order, and twice
const runs = runsOf(items(10), [5, 2, 3, 3, 9, 4]);
expect(runs.map((run) => [run.at, run.rows.map((item) => item.id)])).toEqual([
  [2, [2, 3, 4, 5]],
  [9, [9]],
]);
Places that are not in the list are left out
expect(runsOf(items(3), [-1, 1.5, 3, 2]).map((run) => run.at)).toEqual([2]);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/record-changes.json
Source

src/primitives/record-changes.ts

/**
 * Changes to a list of records, as values: what a grid, a list editor or a
 * collaborator did, in a form that can be told to the data's owner, applied,
 * and turned about to undo it.
 *
 * - `cells` — fields of records that stay where they are, each with the value
 *   it takes and the one it held.
 * - `insert` — records put in at a place.
 * - `remove` — records taken out, in runs, each at its place in the list as it
 *   was before any was taken.
 *
 * Applying a change shares every record it does not touch, so that whatever
 * else shows the data sees only what changed. The inverse of a change, applied
 * after it, gives back the list as it was: that is all undo needs.
 */
export type AnyRecord = Readonly<Record<string, unknown>>;

/** One field's change: where, what it holds now, and what it held. */
export interface CellChange {
  readonly row: number;
  /** The field's key in the record. */
  readonly key: string;
  readonly value: unknown;
  readonly previous: unknown;
}

/** Records side by side, and where the first of them is. */
export interface Run<R = AnyRecord> {
  readonly at: number;
  readonly rows: readonly R[];
}

export type RecordChange<R = AnyRecord> =
  | { readonly kind: "cells"; readonly cells: readonly CellChange[] }
  | ({ readonly kind: "insert" } & Run<R>)
  | { readonly kind: "remove"; readonly runs: ReadonlyArray<Run<R>> };

/** The list after a change. Records the change does not touch are the same objects. */
export function applyChange<R extends AnyRecord>(rows: readonly R[], change: RecordChange<R>): R[] {
  switch (change.kind) {
    case "cells": {
      const next = rows.slice();
      for (const { row, key, value } of change.cells) {
        // A change to a record that is not there has nothing to change.
        if (row >= 0 && row < next.length) next[row] = { ...next[row], [key]: value } as R;
      }
      return next;
    }
    case "insert":
      return rows.toSpliced(Math.min(Math.max(change.at, 0), rows.length), 0, ...change.rows);
    case "remove": {
      // One pass: a run is skipped as it is reached, however many there are.
      const next: R[] = [];
      let from = 0;
      for (const run of change.runs) {
        for (let index = from; index < run.at && index < rows.length; index += 1) next.push(rows[index]!);
        from = Math.max(from, run.at + run.rows.length);
      }
      for (let index = from; index < rows.length; index += 1) next.push(rows[index]!);
      return next;
    }
  }
}

/** The changes that undo a change, in the order to apply them. */
export function invertChange<R extends AnyRecord>(change: RecordChange<R>): Array<RecordChange<R>> {
  switch (change.kind) {
    case "cells":
      return [
        {
          kind: "cells",
          cells: change.cells.map((cell) => ({ ...cell, value: cell.previous, previous: cell.value })),
        },
      ];
    case "insert":
      return [{ kind: "remove", runs: [{ at: change.at, rows: change.rows }] }];
    case "remove":
      // Put back first to last: each run's place is its place once those before it are back.
      return change.runs.map((run) => ({ kind: "insert", at: run.at, rows: run.rows }));
  }
}

/**
 * The runs of records at the given places: the form a removal takes. The
 * places may come in any order, and more than once.
 */
export function runsOf<R extends AnyRecord>(rows: readonly R[], indexes: Iterable<number>): Array<Run<R>> {
  const sorted = [...new Set(indexes)]
    .filter((index) => Number.isInteger(index) && index >= 0 && index < rows.length)
    .toSorted((a, b) => a - b);
  const runs: Array<{ at: number; rows: R[] }> = [];
  for (const index of sorted) {
    const last = runs.at(-1);
    if (last !== undefined && last.at + last.rows.length === index) last.rows.push(rows[index]!);
    else runs.push({ at: index, rows: [rows[index]!] });
  }
  return runs;
}