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 @@