diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 5d7c68f093..c89c980ec9 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -135,6 +135,8 @@ import { canFormatCellDetailJson, cellDetailEditorText, compactJsonText, default import { buildDataGridCellDetail, buildDataGridColumnDetail, buildDataGridRowDetail, CELL_DETAIL_VALUE_PREVIEW_MAX_LENGTH, dataGridColumnDetailJson, dataGridColumnDetailTsv, dataGridRowDetailJson, dataGridRowDetailTsv, type DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; import { applyColumnFormatter, buildColumnFormatterKey, getSupportedTimeZoneOptions, normalizeColumnFormatter, resolveColumnFormatter, type ColumnFormatterConfig, type DateTimeFormatterUnit, DateTimePatterns } from "@/lib/dataGrid/columnFormatter"; import { temporalCellEditorConfig, type TemporalCellEditorConfig } from "@/lib/dataGrid/dataGridTemporalEditor"; +import { isBooleanCheckboxValue, isBooleanColumnType, isPointInBooleanCheckbox, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn"; +import { resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata"; import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts"; import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination"; @@ -2945,6 +2947,7 @@ const { startEdit, commitEdit, commitEditAndMaybeAutoSave, + cycleBooleanCellValue, commitEditFromBlur, applyCellValue, restoreCellValue, @@ -3112,6 +3115,7 @@ function showReadonlyCellDetailsOnDblClick(item: RowItem, rowIndex: number, visi } function onDomCellDblClick(item: RowItem, rowIndex: number, visibleColIdx: number, actualColIdx: number, event: MouseEvent) { + if (isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return; if (showReadonlyCellDetailsOnDblClick(item, rowIndex, visibleColIdx, actualColIdx)) return; startDomCellEdit(item.id, actualColIdx, formatCellCached(item.data[actualColIdx], actualColIdx), event); } @@ -3151,10 +3155,29 @@ function measureCellTextWidth(text: string, font: string): number { return width; } +const cellTextWidthCache = new Map(); + +function measureCellTextWidthCached(text: string, font: string): number { + const key = `${font}|${text}`; + let width = cellTextWidthCache.get(key); + if (width === undefined) { + width = measureCellTextWidth(text, font); + if (cellTextWidthCache.size >= 200) cellTextWidthCache.clear(); + cellTextWidthCache.set(key, width); + } + return width; +} + +const tableColumnsByResultIndex = computed(() => + resolveDataGridColumnsByResultIndex({ + resultColumns: props.result.columns, + sourceColumns: props.sourceColumns, + tableColumns: props.tableMeta?.columns ?? [], + }), +); + function tableColumnForGridColumn(columnIndex: number): ColumnInfo | undefined { - const columnName = props.sourceColumns?.[columnIndex] ?? props.result.columns[columnIndex]; - if (!columnName) return undefined; - return props.tableMeta?.columns.find((column) => column.name.toLowerCase() === columnName.toLowerCase()); + return tableColumnsByResultIndex.value[columnIndex]; } function resultColumnInfoForGridColumn(columnIndex: number): Pick | undefined { @@ -3183,6 +3206,35 @@ function isEnumEditorInitialNull(rowId: number | undefined, columnIndex: number) return getRowItem(rowId)?.data[columnIndex] === null; } +const booleanGridColumns = computed(() => + props.result.columns.map((_, columnIndex) => { + const columnInfo = tableColumnsByResultIndex.value[columnIndex] ?? resultColumnInfoForGridColumn(columnIndex); + return isBooleanColumnType(columnInfo?.data_type, props.databaseType); + }), +); + +function isBooleanGridColumn(columnIndex: number): boolean { + return booleanGridColumns.value[columnIndex] === true; +} + +function isBooleanGridCell(item: RowItem | undefined, columnIndex: number): boolean { + return !!item && isBooleanGridColumn(columnIndex) && isBooleanCheckboxValue(item.data[columnIndex]); +} + +function isBooleanGridColumnNullable(columnIndex: number): boolean { + return tableColumnForGridColumn(columnIndex)?.is_nullable ?? true; +} + +function booleanCellChecked(value: unknown): boolean { + return normalizeBooleanCellValue(value) === true; +} + +function cycleBooleanGridCell(item: RowItem | undefined, actualColIdx: number, event: MouseEvent) { + if (!item || !isBooleanGridCell(item, actualColIdx) || !canEditCellItem(item, actualColIdx)) return; + event.stopPropagation(); + void cycleBooleanCellValue(item.id, actualColIdx, isBooleanGridColumnNullable(actualColIdx)); +} + function cellEditInputModeForColumn(columnIndex: number): "decimal" | "numeric" | undefined { const dataType = normalizedColumnDataType(tableColumnForGridColumn(columnIndex)); if (isIntegerColumnType(dataType)) return "numeric"; @@ -4967,7 +5019,8 @@ function onCanvasMouseMove(event: MouseEvent) { const next = hit && hitItem ? { rowIndex: hitItem.displayIndex, visibleColIdx: hit.rowNumber ? -1 : hit.visibleColIdx } : null; const actualColIdx = next ? visibleColumnIndexes.value[next.visibleColIdx] : undefined; if (canvasRef.value) { - canvasRef.value.style.cursor = hit?.rowNumber ? "default" : hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx) ? "text" : "cell"; + const overBooleanInteractive = hit != null && !hit.rowNumber && hitItem != null && actualColIdx !== undefined && isBooleanGridCell(hitItem, actualColIdx) && canEditCellItem(hitItem, actualColIdx) && booleanInteractiveHitFromCanvasEvent(hitItem, hit, actualColIdx, event); + canvasRef.value.style.cursor = hit?.rowNumber ? "default" : overBooleanInteractive ? "pointer" : hitItem && actualColIdx !== undefined && canEditCellItem(hitItem, actualColIdx) ? "text" : "cell"; } if (next?.rowIndex === canvasHoverCell.value?.rowIndex && next?.visibleColIdx === canvasHoverCell.value?.visibleColIdx) { return; @@ -5010,6 +5063,43 @@ function clearCanvasDetailHover(event?: MouseEvent) { onCanvasMouseLeave(); } +function booleanCheckboxHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { + if (item.data[actualColIdx] === null) return false; + const canvas = canvasRef.value; + const canvasRect = canvas?.getBoundingClientRect(); + const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx); + if (!canvasRect || !cellRect) return false; + return isPointInBooleanCheckbox({ x: event.clientX - canvasRect.left, y: event.clientY - canvasRect.top }, { left: cellRect.left, top: cellRect.top, width: cellRect.width, height: cellRect.height }); +} + +function booleanNullTextHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { + if (item.data[actualColIdx] !== null) return false; + const canvas = canvasRef.value; + const canvasRect = canvas?.getBoundingClientRect(); + const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx); + if (!canvasRect || !cellRect) return false; + const text = firstLineCellDisplayValue(formatCellCached(item.data[actualColIdx], actualColIdx)); + if (!text) return false; + const textWidth = measureCellTextWidthCached(text, `italic 400 ${tableFontSize.value}px ${tableFontFamily.value}`); + if (textWidth <= 0) return false; + const left = cellRect.left + (cellRect.width - textWidth) / 2 - 2; + const right = left + textWidth + 4; + const x = event.clientX - canvasRect.left; + const y = event.clientY - canvasRect.top; + return x >= left && x <= right && y >= cellRect.top && y <= cellRect.top + cellRect.height; +} + +function booleanInteractiveHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { + return item.data[actualColIdx] === null ? booleanNullTextHitFromCanvasEvent(item, hit, actualColIdx, event) : booleanCheckboxHitFromCanvasEvent(item, hit, actualColIdx, event); +} + +function tryCycleBooleanCheckboxOnCanvasMouseDown(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean { + if (!isBooleanGridCell(item, actualColIdx) || !canEditCellItem(item, actualColIdx)) return false; + if (!booleanInteractiveHitFromCanvasEvent(item, hit, actualColIdx, event)) return false; + void cycleBooleanCellValue(item.id, actualColIdx, isBooleanGridColumnNullable(actualColIdx)); + return true; +} + function onCanvasMouseDown(event: MouseEvent) { if (event.button !== 0) return; const hit = canvasHitTest(event); @@ -5026,6 +5116,7 @@ function onCanvasMouseDown(event: MouseEvent) { onRowNumberMouseDown(item, event); } else { handleDataCellMousedown(item.displayIndex, hit.visibleColIdx, item.id, event); + if (actualColIdx !== undefined) tryCycleBooleanCheckboxOnCanvasMouseDown(item, hit, actualColIdx, event); } gridRef.value?.focus({ preventScroll: true }); scheduleCanvasDraw(); @@ -5058,6 +5149,7 @@ function onCanvasDblClick(event: MouseEvent) { const actualColIdx = visibleColumnIndexes.value[hit.visibleColIdx]; if (!item || actualColIdx === undefined) return; if (showReadonlyCellDetailsOnDblClick(item, item.displayIndex, hit.visibleColIdx, actualColIdx)) return; + if (isBooleanGridCell(item, actualColIdx) && canEditCellItem(item, actualColIdx)) return; startCellEdit(item.id, actualColIdx, canvasCellContentOverflows(item, actualColIdx, hit.visibleColIdx)); } @@ -5222,6 +5314,7 @@ function drawCanvasGrid() { searchMatchKeys: searchMatchSet.value, currentSearchMatch: currentSearchMatch.value, formatCell: formatCellCached, + columnIsBoolean: isBooleanGridColumn, draftCellPlaceholder: t("grid.quickEntryDraftPlaceholder"), isRowActive, rowCellsUseSelectionVisual, @@ -5712,6 +5805,7 @@ function onTransposeCellDblClick(rowIndex: number, actualColIdx: number, display showTransposeCellDetails(rowIndex, actualColIdx); return; } + if (isBooleanGridCell(item, actualColIdx)) return; startDomCellEdit(item.id, actualColIdx, displayText, event); } @@ -6212,6 +6306,10 @@ function editSelectedCell(): boolean { const item = displayItemAt(position.rowIndex); const actualColIndex = actualColumnIndex(position.colIndex); if (!item || !canEditCellItem(item, actualColIndex)) return false; + if (isBooleanGridCell(item, actualColIndex)) { + void cycleBooleanCellValue(item.id, actualColIndex, isBooleanGridColumnNullable(actualColIndex)); + return true; + } startEdit(item.id, actualColIndex); return true; } @@ -8639,6 +8737,22 @@ const gridContextMenuItems = computed(() => { + +
(() => { + +
; currentSearchMatch: CanvasSearchMatch | null; formatCell: (value: CellValue, columnIndex: number) => string; + columnIsBoolean?: (columnIndex: number) => boolean; draftCellPlaceholder?: string; isRowActive: (rowIndex: number) => boolean; rowCellsUseSelectionVisual: (rowId: number) => boolean; @@ -191,6 +193,29 @@ function alignCanvasPixel(value: number, dpr: number): number { return Math.round(value * dpr) / dpr; } +function drawBooleanCheckbox(ctx: CanvasRenderingContext2D, options: { drawX: number; y: number; colWidth: number; dpr: number; theme: DataGridPaintTheme; checked: boolean }): void { + const { drawX, y, colWidth, dpr, theme, checked } = options; + const size = BOOLEAN_CHECKBOX_SIZE; + const boxX = alignCanvasPixel(drawX + (colWidth - size) / 2, dpr); + const boxY = alignCanvasPixel(y + (CANVAS_DATA_GRID_ROW_HEIGHT - size) / 2, dpr); + ctx.lineWidth = 1; + if (checked) { + ctx.fillStyle = theme.primary; + ctx.fillRect(boxX, boxY, size, size); + ctx.strokeStyle = theme.background; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(boxX + 3, boxY + size / 2); + ctx.lineTo(boxX + size / 2 - 0.5, boxY + size - 3.5); + ctx.lineTo(boxX + size - 2.5, boxY + 3); + ctx.stroke(); + ctx.lineWidth = 1; + } else { + ctx.strokeStyle = theme.mutedForeground; + ctx.strokeRect(boxX + 0.5, boxY + 0.5, size - 1, size - 1); + } +} + function crispCanvasLine(value: number, dpr: number): number { return alignCanvasPixel(value, dpr) + 0.5 / dpr; } @@ -275,6 +300,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { frozenColumnCount = 0, columnAligns, rightAlignedActionCell, + columnIsBoolean, } = options; const dpr = Math.max(1, options.pixelRatio ?? window.devicePixelRatio ?? 1); const pixelWidth = Math.max(1, Math.ceil(width * dpr)); @@ -437,26 +463,43 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) { ctx.rect(clippedX, y, Math.min(cellPaintWidth, width - clippedX), CANVAS_DATA_GRID_ROW_HEIGHT); ctx.clip(); const value = item.data[actualColIdx]; + const isBooleanCell = columnIsBoolean?.(actualColIdx) === true && isBooleanCheckboxValue(value); const isRightAlign = columnAligns?.[visibleColIdx] === "right"; - ctx.textAlign = isRightAlign ? "right" : "left"; + const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx; + const isBooleanNullCell = isBooleanCell && value === null && !isEditingThisCell; + ctx.textAlign = isBooleanNullCell ? "center" : isRightAlign ? "right" : "left"; ctx.fillStyle = value === null ? theme.mutedForeground : theme.foreground; ctx.font = value === null ? italicFont : tabularFont; setCanvasNumericVariant(ctx, value === null ? "normal" : "tabular-nums"); const reservedWidth = rightAlignedActionCell?.rowIndex === item.displayIndex && rightAlignedActionCell.visibleColIdx === visibleColIdx ? rightAlignedActionCell.reservedWidth : 0; const { textAnchorX, maxWidth: cellMaxWidth } = resolveCanvasCellTextLayout({ drawX, colWidth, dpr, isRightAlign, reservedWidth }); - const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx; - const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx); - const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText); - const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isRightAlign ? "right" : "left"); - ctx.fillText(text, textAnchorX, textY); - if (item.isDeleted && text) { - const textWidth = ctx.measureText(text).width; - const lineStartX = isRightAlign ? textAnchorX - textWidth : textAnchorX; - ctx.strokeStyle = theme.foreground; - ctx.beginPath(); - ctx.moveTo(lineStartX, textY); - ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, dpr), textY); - ctx.stroke(); + if (isBooleanCell && value !== null && !isEditingThisCell) { + drawBooleanCheckbox(ctx, { drawX, y, colWidth, dpr, theme, checked: normalizeBooleanCellValue(value) === true }); + if (item.isDeleted) { + const boxX = alignCanvasPixel(drawX + (colWidth - BOOLEAN_CHECKBOX_SIZE) / 2, dpr); + const strikeY = alignCanvasPixel(y + CANVAS_DATA_GRID_ROW_HEIGHT / 2, dpr); + ctx.strokeStyle = theme.foreground; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(boxX - 1, strikeY); + ctx.lineTo(alignCanvasPixel(boxX + BOOLEAN_CHECKBOX_SIZE + 1, dpr), strikeY); + ctx.stroke(); + } + } else { + const rawDisplayText = item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx); + const displayText = isEditingThisCell ? "" : firstLineCellDisplayValue(rawDisplayText); + const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, cellMaxWidth, isBooleanNullCell ? "left" : isRightAlign ? "right" : "left"); + const anchorX = isBooleanNullCell ? alignCanvasPixel(drawX + colWidth / 2, dpr) : textAnchorX; + ctx.fillText(text, anchorX, textY); + if (item.isDeleted && text) { + const textWidth = ctx.measureText(text).width; + const lineStartX = isBooleanNullCell ? anchorX - textWidth / 2 : isRightAlign ? textAnchorX - textWidth : textAnchorX; + ctx.strokeStyle = theme.foreground; + ctx.beginPath(); + ctx.moveTo(lineStartX, textY); + ctx.lineTo(alignCanvasPixel(lineStartX + textWidth, dpr), textY); + ctx.stroke(); + } } ctx.restore(); setCanvasNumericVariant(ctx, "normal"); diff --git a/apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts b/apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts new file mode 100644 index 0000000000..d75c9a9d90 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts @@ -0,0 +1,51 @@ +import type { DatabaseType } from "@/types/database"; + +export const BOOLEAN_CHECKBOX_SIZE = 13; + +const MYSQL_BIT_BOOLEAN_DATABASE_TYPES = new Set(["mysql"]); + +export function isBooleanColumnType(dataType: string | undefined, databaseType?: DatabaseType): boolean { + if (!dataType) return false; + const normalized = dataType.trim().toLowerCase(); + if (normalized === "boolean" || normalized === "bool") return true; + if (databaseType === "sqlserver") return normalized === "bit"; + if (databaseType && MYSQL_BIT_BOOLEAN_DATABASE_TYPES.has(databaseType)) return normalized === "bit" || normalized === "bit(1)"; + return false; +} + +export function normalizeBooleanCellValue(value: unknown): boolean | null { + if (value === null || value === undefined) return null; + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "t" || normalized === "yes") return true; + if (normalized === "false" || normalized === "0" || normalized === "f" || normalized === "no") return false; + return null; + } + return null; +} + +export function isBooleanCheckboxValue(value: unknown): boolean { + return value === null || normalizeBooleanCellValue(value) !== null; +} + +export function nextBooleanCellValue(current: unknown, nullable: boolean): boolean | null { + const normalized = normalizeBooleanCellValue(current); + if (normalized === true) return false; + if (normalized === false) return nullable ? null : true; + return true; +} + +export function booleanCheckboxRect(cell: { left: number; top: number; width: number; height: number }): { left: number; top: number; size: number } { + return { + left: cell.left + (cell.width - BOOLEAN_CHECKBOX_SIZE) / 2, + top: cell.top + (cell.height - BOOLEAN_CHECKBOX_SIZE) / 2, + size: BOOLEAN_CHECKBOX_SIZE, + }; +} + +export function isPointInBooleanCheckbox(point: { x: number; y: number }, cell: { left: number; top: number; width: number; height: number }): boolean { + const rect = booleanCheckboxRect(cell); + return point.x >= rect.left && point.x <= rect.left + rect.size && point.y >= rect.top && point.y <= rect.top + rect.size; +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnMetadata.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnMetadata.ts new file mode 100644 index 0000000000..0c594651c9 --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridColumnMetadata.ts @@ -0,0 +1,13 @@ +import type { ColumnInfo } from "@/types/database"; + +export function resolveDataGridColumnsByResultIndex(options: { resultColumns: readonly string[]; sourceColumns?: readonly (string | undefined)[]; tableColumns: readonly ColumnInfo[] }): Array { + const columnsByName = new Map(); + for (const column of options.tableColumns) { + const key = column.name.toLowerCase(); + if (!columnsByName.has(key)) columnsByName.set(key, column); + } + return options.resultColumns.map((resultColumn, index) => { + const columnName = options.sourceColumns?.[index] ?? resultColumn; + return columnName ? columnsByName.get(columnName.toLowerCase()) : undefined; + }); +} diff --git a/packages/app-tests/dataGridBooleanColumn.test.ts b/packages/app-tests/dataGridBooleanColumn.test.ts new file mode 100644 index 0000000000..f468b6a3c7 --- /dev/null +++ b/packages/app-tests/dataGridBooleanColumn.test.ts @@ -0,0 +1,113 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import { test } from "vitest"; +import { isBooleanCheckboxValue, isBooleanColumnType, nextBooleanCellValue, normalizeBooleanCellValue } from "../../apps/desktop/src/lib/dataGrid/dataGridBooleanColumn.ts"; +import { resolveDataGridColumnsByResultIndex } from "../../apps/desktop/src/lib/dataGrid/dataGridColumnMetadata.ts"; +import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts"; + +function column(name: string, dataType: string): ColumnInfo { + return { + name, + data_type: dataType, + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }; +} + +test("detects boolean types using database semantics", () => { + assert.equal(isBooleanColumnType("boolean"), true); + assert.equal(isBooleanColumnType("bool", "postgres"), true); + assert.equal(isBooleanColumnType("bit", "sqlserver"), true); + assert.equal(isBooleanColumnType("bit", "mysql"), true); + assert.equal(isBooleanColumnType("bit(1)", "mysql"), true); + assert.equal(isBooleanColumnType(" BOOLEAN ", "postgres"), true); +}); + +test("does not treat PostgreSQL bit strings or unknown bit semantics as boolean", () => { + assert.equal(isBooleanColumnType("bit", "postgres"), false); + assert.equal(isBooleanColumnType("bit(1)", "postgres"), false); + assert.equal(isBooleanColumnType("bit varying", "postgres"), false); + assert.equal(isBooleanColumnType("varbit", "postgres"), false); + assert.equal(isBooleanColumnType("bit", "opengauss"), false); + assert.equal(isBooleanColumnType("bit", undefined), false); + assert.equal(isBooleanColumnType("bit(8)", "mysql"), false); + assert.equal(isBooleanColumnType("tinyint(1)", "mysql"), false); + assert.equal(isBooleanColumnType(undefined, "mysql"), false); +}); + +test("normalizes raw cell values to a tri-state boolean", () => { + assert.equal(normalizeBooleanCellValue(true), true); + assert.equal(normalizeBooleanCellValue(false), false); + assert.equal(normalizeBooleanCellValue(1), true); + assert.equal(normalizeBooleanCellValue(0), false); + assert.equal(normalizeBooleanCellValue("true"), true); + assert.equal(normalizeBooleanCellValue("false"), false); + assert.equal(normalizeBooleanCellValue("t"), true); + assert.equal(normalizeBooleanCellValue("0"), false); + assert.equal(normalizeBooleanCellValue(null), null); + assert.equal(normalizeBooleanCellValue(undefined), null); + assert.equal(normalizeBooleanCellValue("maybe"), null); +}); + +test("shows checkboxes only for recognized boolean cell values", () => { + assert.equal(isBooleanCheckboxValue(true), true); + assert.equal(isBooleanCheckboxValue(0), true); + assert.equal(isBooleanCheckboxValue("false"), true); + assert.equal(isBooleanCheckboxValue(null), true); + assert.equal(isBooleanCheckboxValue(undefined), false); + assert.equal(isBooleanCheckboxValue("maybe"), false); + assert.equal(isBooleanCheckboxValue({}), false); +}); + +test("cycles true -> false -> true for NOT NULL columns", () => { + assert.equal(nextBooleanCellValue(true, false), false); + assert.equal(nextBooleanCellValue(false, false), true); + assert.equal(nextBooleanCellValue(null, false), true); +}); + +test("cycles true -> false -> null -> true for nullable columns", () => { + assert.equal(nextBooleanCellValue(true, true), false); + assert.equal(nextBooleanCellValue(false, true), null); + assert.equal(nextBooleanCellValue(null, true), true); +}); + +test("indexes table metadata once and resolves source-column aliases", () => { + const enabled = column("Enabled", "boolean"); + const displayName = column("DisplayName", "varchar"); + const resolved = resolveDataGridColumnsByResultIndex({ + resultColumns: ["enabled_alias", "DisplayName", "missing"], + sourceColumns: ["enabled", undefined, undefined], + tableColumns: [enabled, displayName], + }); + + assert.equal(resolved[0], enabled); + assert.equal(resolved[1], displayName); + assert.equal(resolved[2], undefined); +}); + +test("runs canvas selection before toggling a checkbox", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + const start = source.indexOf("function onCanvasMouseDown"); + const end = source.indexOf("function onCanvasContext", start); + const handler = source.slice(start, end); + const selectionIndex = handler.indexOf("handleDataCellMousedown"); + const toggleIndex = handler.indexOf("tryCycleBooleanCheckboxOnCanvasMouseDown"); + + assert.ok(start >= 0 && end > start); + assert.ok(selectionIndex >= 0); + assert.ok(toggleIndex >= 0); + assert.ok(selectionIndex < toggleIndex); +}); + +test("uses the indexed metadata lookup in grid hot paths", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + const start = source.indexOf("function tableColumnForGridColumn"); + const end = source.indexOf("function resultColumnInfoForGridColumn", start); + const lookup = source.slice(start, end); + + assert.ok(start >= 0 && end > start); + assert.match(lookup, /tableColumnsByResultIndex\.value\[columnIndex\]/); + assert.doesNotMatch(lookup, /\.find\(/); +});