Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions core/ui/modules/batch-update-logic.d.ts
Original file line number Diff line number Diff line change
@@ -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 <input> (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<unknown>;
}

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<number, BatchColumnInfo>;

export function summarizeColumnValue(values: Iterable<unknown>): string;

export function prepareBatchUpdates(
selectedCells: BatchSelectedCell[],
inputsByCol: Map<number, BatchInputLike>,
tableColumns: BatchColumnDef[]
): PreparedBatchUpdate[];
104 changes: 104 additions & 0 deletions core/ui/modules/batch-update-logic.js
Original file line number Diff line number Diff line change
@@ -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()
});
}
Comment on lines +19 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If tableColumns is undefined or cell.colIdx is out of bounds, colDef will be undefined, leading to a TypeError when accessing colDef.name. Adding a defensive check prevents potential runtime crashes.

Suggested change
for (const cell of selectedCells) {
if (!columns.has(cell.colIdx)) {
const colDef = tableColumns[cell.colIdx];
columns.set(cell.colIdx, {
name: colDef.name,
type: colDef.type,
values: new Set()
});
}
for (const cell of selectedCells) {
const colDef = tableColumns && tableColumns[cell.colIdx];
if (!colDef) continue;
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);
}
Comment on lines +39 to +47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If values is empty, uniqueValues.length is 0, and uniqueValues[0] is undefined. This causes the function to return the string 'undefined'. Handling the empty case explicitly avoids returning an unexpected string.

Suggested change
export function summarizeColumnValue(values) {
const uniqueValues = Array.from(values);
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);
}
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 <input> 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;
}
78 changes: 7 additions & 71 deletions core/ui/modules/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading