diff --git a/core/ui/modules/batch-update-logic.d.ts b/core/ui/modules/batch-update-logic.d.ts
new file mode 100644
index 0000000..d451b4c
--- /dev/null
+++ b/core/ui/modules/batch-update-logic.d.ts
@@ -0,0 +1,50 @@
+/**
+ * Types for batch-update-logic.js (DOM-free batch-update helpers).
+ */
+
+export interface BatchSelectedCell {
+ rowId: string | number;
+ rowIdx: number;
+ colIdx: number;
+ value: unknown;
+}
+
+export interface BatchColumnDef {
+ name: string;
+ type?: string;
+}
+
+/** Minimal shape of a batch-field (real element or test stand-in). */
+export interface BatchInputLike {
+ value: string;
+ dataset?: { isnull?: string; ispatch?: string };
+}
+
+export interface BatchColumnInfo {
+ name: string;
+ type?: string;
+ values: Set;
+}
+
+export interface PreparedBatchUpdate {
+ rowId: string | number;
+ column: string;
+ value: unknown;
+ originalValue: unknown;
+ operation: 'set' | 'json_patch';
+ rowIdx: number;
+ colIdx: number;
+}
+
+export function groupSelectedCellsByColumn(
+ selectedCells: BatchSelectedCell[],
+ tableColumns: BatchColumnDef[]
+): Map;
+
+export function summarizeColumnValue(values: Iterable): string;
+
+export function prepareBatchUpdates(
+ selectedCells: BatchSelectedCell[],
+ inputsByCol: Map,
+ tableColumns: BatchColumnDef[]
+): PreparedBatchUpdate[];
diff --git a/core/ui/modules/batch-update-logic.js b/core/ui/modules/batch-update-logic.js
new file mode 100644
index 0000000..f030bbe
--- /dev/null
+++ b/core/ui/modules/batch-update-logic.js
@@ -0,0 +1,104 @@
+/**
+ * Batch Update Logic (DOM-free, unit-testable)
+ *
+ * Pure helpers extracted from sidebar.js's batch-update flow so the
+ * value-processing rules (column grouping, value summarisation, type
+ * coercion, NULL/json_patch handling, skip-empty) can be unit-tested
+ * without a DOM. sidebar.js wires these into the actual DOM.
+ *
+ * Types live in batch-update-logic.d.ts.
+ */
+
+/**
+ * Group the selected cells by column index.
+ * @returns Map of colIdx -> { name, type, values } where `values` is the set
+ * of distinct cell values currently selected in that column.
+ */
+export function groupSelectedCellsByColumn(selectedCells, tableColumns) {
+ const columns = new Map();
+ for (const cell of selectedCells) {
+ const colDef = tableColumns && tableColumns[cell.colIdx];
+ if (!colDef) continue; // skip stale/out-of-bounds selections (e.g. after a column drop)
+ if (!columns.has(cell.colIdx)) {
+ columns.set(cell.colIdx, {
+ name: colDef.name,
+ type: colDef.type,
+ values: new Set()
+ });
+ }
+ columns.get(cell.colIdx).values.add(cell.value);
+ }
+ return columns;
+}
+
+/**
+ * Placeholder text describing a column's current selected value(s):
+ * '(mixed values)' when the selection spans differing values, otherwise the
+ * single shared value rendered as NULL / [BLOB] / its string form.
+ */
+export function summarizeColumnValue(values) {
+ const uniqueValues = Array.from(values || []);
+ if (uniqueValues.length === 0) return '';
+ if (uniqueValues.length > 1) return '(mixed values)';
+ const val = uniqueValues[0];
+ if (val === null) return 'NULL';
+ if (val instanceof Uint8Array) return '[BLOB]';
+ return String(val);
+}
+
+/**
+ * Build the list of cell updates to send to the backend.
+ *
+ * `inputsByCol` maps a column index to an input-like object
+ * ({ value, dataset:{ isnull, ispatch } }) — in the browser these are the
+ * real elements; in tests they are plain stand-ins. Mirrors the
+ * batch form's rules: skip cells left blank (unless explicitly NULL), tag
+ * json_patch operations, and coerce numeric column types.
+ */
+export function prepareBatchUpdates(selectedCells, inputsByCol, tableColumns) {
+ const updates = [];
+ for (const cell of selectedCells) {
+ const input = inputsByCol.get(cell.colIdx);
+ if (!input) continue;
+
+ const dataset = input.dataset || {};
+ const isNull = dataset.isnull === 'true';
+ const isPatch = dataset.ispatch === 'true';
+ const value = input.value;
+
+ // Skip cells left blank unless they were explicitly set to NULL.
+ if (value === '' && !isNull) continue;
+
+ const colDef = tableColumns && tableColumns[cell.colIdx];
+ if (!colDef) continue; // skip stale/out-of-bounds selections
+
+ let finalValue = value;
+ let operation = 'set';
+
+ if (isNull) {
+ finalValue = null;
+ } else if (isPatch) {
+ operation = 'json_patch';
+ } else {
+ // Coerce numeric column types when the input parses as a number.
+ // Normalize case: SQLite stores the declared type verbatim, so a column
+ // may report e.g. 'integer' rather than 'INTEGER'.
+ const colType = (colDef.type || '').toUpperCase();
+ if ((colType === 'INTEGER' || colType === 'REAL' || colType === 'NUMERIC')
+ && !isNaN(Number(value)) && value.trim() !== '') {
+ finalValue = Number(value);
+ }
+ }
+
+ updates.push({
+ rowId: cell.rowId,
+ column: colDef.name,
+ value: finalValue,
+ originalValue: cell.value,
+ operation,
+ rowIdx: cell.rowIdx,
+ colIdx: cell.colIdx
+ });
+ }
+ return updates;
+}
diff --git a/core/ui/modules/sidebar.js b/core/ui/modules/sidebar.js
index 2949e35..794e8f7 100644
--- a/core/ui/modules/sidebar.js
+++ b/core/ui/modules/sidebar.js
@@ -3,12 +3,12 @@
*/
import { state, persistState } from './state.js';
import { backendApi } from './api.js';
-import { escapeHtml } from './utils.js';
import { updateStatus } from './ui.js';
import { loadTableData, loadTableColumns } from './grid.js';
import { getRowDataOffset } from './data-utils.js';
import { openCreateTableModal } from './crud.js';
import { openSettingsModal } from './settings.js';
+import { groupSelectedCellsByColumn, summarizeColumnValue, prepareBatchUpdates } from './batch-update-logic.js';
export function initSidebar() {
const sidebarPanel = document.getElementById('sidebarPanel');
@@ -254,36 +254,13 @@ export function updateBatchSidebar() {
countBadge.textContent = cellCount;
- // Analyze selected cells - Group by column
- const columns = new Map();
-
- for (const cell of state.selectedCells) {
- if (!columns.has(cell.colIdx)) {
- const colDef = state.tableColumns[cell.colIdx];
- columns.set(cell.colIdx, {
- name: colDef.name,
- type: colDef.type,
- values: new Set()
- });
- }
- columns.get(cell.colIdx).values.add(cell.value);
- }
+ // Analyze selected cells - group by column (see batch-update-logic.js)
+ const columns = groupSelectedCellsByColumn(state.selectedCells, state.tableColumns);
fieldsContainer.replaceChildren();
for (const [colIdx, colInfo] of columns) {
- const uniqueValues = Array.from(colInfo.values);
- const isMixed = uniqueValues.length > 1;
-
- let valueDisplay = '';
- if (isMixed) {
- valueDisplay = '(mixed values)';
- } else {
- const val = uniqueValues[0];
- if (val === null) valueDisplay = 'NULL';
- else if (val instanceof Uint8Array) valueDisplay = '[BLOB]';
- else valueDisplay = String(val);
- }
+ const valueDisplay = summarizeColumnValue(colInfo.values);
const div = document.createElement('div');
div.className = 'form-field batch-field';
@@ -352,55 +329,14 @@ export async function applyBatchUpdate() {
JSON.parse(input.value);
} catch (e) {
const colDef = state.tableColumns[colIdx];
- updateStatus(`Invalid JSON for patch in ${colDef.name}`);
+ updateStatus(`Invalid JSON for patch in ${colDef?.name ?? `column ${colIdx}`}`);
return;
}
}
}
- const updates = [];
-
- // 2. Processing Phase
- for (const cell of state.selectedCells) {
- const input = inputsByCol.get(cell.colIdx);
- if (!input) continue;
-
- const isNull = input.dataset.isnull === 'true';
- const isPatch = input.dataset.ispatch === 'true';
- const value = input.value;
-
- // Skip if empty and not explicitly set to NULL (and not patch with content)
- if (value === "" && !isNull) continue;
-
- const colDef = state.tableColumns[cell.colIdx];
-
- // Prepare value
- let finalValue = value;
- let operation = 'set';
-
- if (isNull) {
- finalValue = null;
- } else if (isPatch) {
- operation = 'json_patch';
- } else {
- // Basic type coercion
- if (colDef.type === 'INTEGER' || colDef.type === 'REAL' || colDef.type === 'NUMERIC') {
- if (!isNaN(Number(value)) && value.trim() !== '') {
- finalValue = Number(value);
- }
- }
- }
-
- updates.push({
- rowId: cell.rowId,
- column: colDef.name,
- value: finalValue,
- originalValue: cell.value,
- operation,
- rowIdx: cell.rowIdx, // Local metadata
- colIdx: cell.colIdx // Local metadata
- });
- }
+ // 2. Processing Phase — value coercion / NULL / json_patch (see batch-update-logic.js)
+ const updates = prepareBatchUpdates(state.selectedCells, inputsByCol, state.tableColumns);
if (updates.length === 0) {
updateStatus('No values entered for batch update');
diff --git a/core/ui/viewer.html b/core/ui/viewer.html
index a085bbb..e632cbb 100644
--- a/core/ui/viewer.html
+++ b/core/ui/viewer.html
@@ -364,7 +364,7 @@
diff --git a/tests/unit/batch-update-logic.test.ts b/tests/unit/batch-update-logic.test.ts
new file mode 100644
index 0000000..c205b18
--- /dev/null
+++ b/tests/unit/batch-update-logic.test.ts
@@ -0,0 +1,132 @@
+/**
+ * Unit tests for the DOM-free batch-update logic extracted from sidebar.js.
+ * Covers the value-processing rules that drive the batch-update form.
+ */
+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+import {
+ groupSelectedCellsByColumn,
+ summarizeColumnValue,
+ prepareBatchUpdates,
+} from '../../core/ui/modules/batch-update-logic.js';
+import type {
+ BatchSelectedCell,
+ BatchColumnDef,
+ BatchInputLike,
+} from '../../core/ui/modules/batch-update-logic.js';
+
+const columns: BatchColumnDef[] = [
+ { name: 'id', type: 'INTEGER' },
+ { name: 'name', type: 'TEXT' },
+ { name: 'price', type: 'REAL' },
+ { name: 'meta', type: 'TEXT' },
+];
+
+const cell = (rowIdx: number, colIdx: number, value: unknown): BatchSelectedCell =>
+ ({ rowId: rowIdx + 1, rowIdx, colIdx, value });
+
+const input = (value: string, opts: { isnull?: boolean; ispatch?: boolean } = {}): BatchInputLike =>
+ ({ value, dataset: { isnull: opts.isnull ? 'true' : 'false', ispatch: opts.ispatch ? 'true' : 'false' } });
+
+describe('groupSelectedCellsByColumn', () => {
+ it('groups cells by column with distinct value sets', () => {
+ const grouped = groupSelectedCellsByColumn(
+ [cell(0, 1, 'a'), cell(1, 1, 'b'), cell(2, 1, 'a'), cell(0, 0, 1)],
+ columns
+ );
+ assert.strictEqual(grouped.size, 2);
+ assert.strictEqual(grouped.get(1)!.name, 'name');
+ assert.deepStrictEqual([...grouped.get(1)!.values], ['a', 'b']); // distinct
+ assert.deepStrictEqual([...grouped.get(0)!.values], [1]);
+ });
+});
+
+describe('summarizeColumnValue', () => {
+ it('shows the single shared value', () => {
+ assert.strictEqual(summarizeColumnValue(new Set(['hello'])), 'hello');
+ assert.strictEqual(summarizeColumnValue(new Set([42])), '42');
+ });
+ it('shows NULL for null and [BLOB] for binary', () => {
+ assert.strictEqual(summarizeColumnValue(new Set([null])), 'NULL');
+ assert.strictEqual(summarizeColumnValue(new Set([new Uint8Array([1, 2])])), '[BLOB]');
+ });
+ it('shows (mixed values) when the selection spans differing values', () => {
+ assert.strictEqual(summarizeColumnValue(new Set(['a', 'b'])), '(mixed values)');
+ });
+});
+
+describe('prepareBatchUpdates', () => {
+ it('coerces INTEGER columns to numbers (operation set)', () => {
+ const [u] = prepareBatchUpdates([cell(0, 0, 5)], new Map([[0, input('42')]]), columns);
+ assert.strictEqual(u.value, 42);
+ assert.strictEqual(typeof u.value, 'number');
+ assert.strictEqual(u.operation, 'set');
+ assert.strictEqual(u.column, 'id');
+ assert.strictEqual(u.originalValue, 5);
+ });
+ it('coerces REAL columns', () => {
+ const [u] = prepareBatchUpdates([cell(0, 2, 1)], new Map([[2, input('3.14')]]), columns);
+ assert.strictEqual(u.value, 3.14);
+ });
+ it('does not coerce non-numeric input in a numeric column', () => {
+ const [u] = prepareBatchUpdates([cell(0, 0, 1)], new Map([[0, input('abc')]]), columns);
+ assert.strictEqual(u.value, 'abc');
+ });
+ it('leaves TEXT values as strings', () => {
+ const [u] = prepareBatchUpdates([cell(0, 1, 'x')], new Map([[1, input('hello')]]), columns);
+ assert.strictEqual(u.value, 'hello');
+ });
+ it('sets value to null when the field is marked NULL', () => {
+ const [u] = prepareBatchUpdates([cell(0, 1, 'x')], new Map([[1, input('', { isnull: true })]]), columns);
+ assert.strictEqual(u.value, null);
+ assert.strictEqual(u.operation, 'set');
+ });
+ it('tags json_patch and keeps the raw patch string', () => {
+ const [u] = prepareBatchUpdates([cell(0, 3, '{}')], new Map([[3, input('{"a":1}', { ispatch: true })]]), columns);
+ assert.strictEqual(u.operation, 'json_patch');
+ assert.strictEqual(u.value, '{"a":1}');
+ });
+ it('skips cells left blank unless explicitly NULL', () => {
+ assert.strictEqual(prepareBatchUpdates([cell(0, 1, 'x')], new Map([[1, input('')]]), columns).length, 0);
+ });
+ it('skips cells whose column has no input', () => {
+ assert.strictEqual(prepareBatchUpdates([cell(0, 1, 'x')], new Map(), columns).length, 0);
+ });
+ it('processes multiple cells and preserves row/col metadata', () => {
+ const updates = prepareBatchUpdates([cell(0, 1, 'x'), cell(1, 1, 'y')], new Map([[1, input('Z')]]), columns);
+ assert.strictEqual(updates.length, 2);
+ assert.deepStrictEqual(updates.map(u => u.rowIdx), [0, 1]);
+ assert.strictEqual(updates[0].colIdx, 1);
+ });
+});
+
+describe('batch-update-logic hardening (edge cases)', () => {
+ it('groupSelectedCellsByColumn skips out-of-bounds column indices', () => {
+ const grouped = groupSelectedCellsByColumn([cell(0, 99, 'x'), cell(0, 1, 'a')], columns);
+ assert.strictEqual(grouped.size, 1);
+ assert.ok(grouped.has(1));
+ assert.ok(!grouped.has(99));
+ });
+ it('summarizeColumnValue returns empty string for an empty set', () => {
+ assert.strictEqual(summarizeColumnValue(new Set()), '');
+ });
+ it('prepareBatchUpdates skips cells whose column is out of bounds (e.g. after a column drop)', () => {
+ assert.strictEqual(prepareBatchUpdates([cell(0, 99, 'x')], new Map([[99, input('v')]]), columns).length, 0);
+ });
+ it('prepareBatchUpdates tolerates an input without a dataset', () => {
+ const [u] = prepareBatchUpdates([cell(0, 1, 'x')], new Map([[1, { value: 'hi' } as BatchInputLike]]), columns);
+ assert.strictEqual(u.value, 'hi');
+ assert.strictEqual(u.operation, 'set');
+ });
+ it('coerces NUMERIC columns', () => {
+ const cols: BatchColumnDef[] = [{ name: 'qty', type: 'NUMERIC' }];
+ const [u] = prepareBatchUpdates([cell(0, 0, 1)], new Map([[0, input('7')]]), cols);
+ assert.strictEqual(u.value, 7);
+ });
+ it('coerces lowercase numeric column types (e.g. "integer")', () => {
+ const cols: BatchColumnDef[] = [{ name: 'n', type: 'integer' }];
+ const [u] = prepareBatchUpdates([cell(0, 0, 1)], new Map([[0, input('42')]]), cols);
+ assert.strictEqual(u.value, 42);
+ assert.strictEqual(typeof u.value, 'number');
+ });
+});
diff --git a/website/public/sqlite-viewer/viewer.html b/website/public/sqlite-viewer/viewer.html
index 7286041..30ab11c 100644
--- a/website/public/sqlite-viewer/viewer.html
+++ b/website/public/sqlite-viewer/viewer.html
@@ -364,7 +364,7 @@