feat(grid): add boolean cell checkbox editing
This commit is contained in:
parent
a537565875
commit
a49e795680
|
|
@ -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<string, number>();
|
||||
|
||||
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<ColumnInfo, "data_type"> | 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<ContextMenuItem[]>(() => {
|
|||
<template v-if="draftCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex)">
|
||||
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex) }}</span>
|
||||
</template>
|
||||
<template v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex) && !cell.isNull">
|
||||
<span class="flex w-full justify-center">
|
||||
<span
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border"
|
||||
:class="[booleanCellChecked(displayItems[cell.recordIndex]?.data[cell.valueIndex]) ? 'border-primary bg-primary text-primary-foreground' : '', canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) ? 'cursor-pointer' : '']"
|
||||
@click="cycleBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex, $event)"
|
||||
>
|
||||
<Check v-if="booleanCellChecked(displayItems[cell.recordIndex]?.data[cell.valueIndex])" class="h-3 w-3" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex)">
|
||||
<span class="flex w-full justify-center">
|
||||
<span :class="canEditCellItem(displayItems[cell.recordIndex], cell.valueIndex) ? 'cursor-pointer' : ''" @click="cycleBooleanGridCell(displayItems[cell.recordIndex], cell.valueIndex, $event)">{{ firstLineCellDisplayValue(cell.display) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ firstLineCellDisplayValue(cell.display) }}</template>
|
||||
<div v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<LightDropdownMenu
|
||||
|
|
@ -9307,6 +9421,22 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
<template v-if="draftCellPlaceholder(item, col.actualColIdx)">
|
||||
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(item, col.actualColIdx) }}</span>
|
||||
</template>
|
||||
<template v-else-if="isBooleanGridCell(item, col.actualColIdx) && !isNull(item.data[col.actualColIdx])">
|
||||
<span class="flex w-full justify-center">
|
||||
<span
|
||||
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border"
|
||||
:class="[booleanCellChecked(item.data[col.actualColIdx]) ? 'border-primary bg-primary text-primary-foreground' : '', canEditCellItem(item, col.actualColIdx) ? 'cursor-pointer' : '']"
|
||||
@click="cycleBooleanGridCell(item, col.actualColIdx, $event)"
|
||||
>
|
||||
<Check v-if="booleanCellChecked(item.data[col.actualColIdx])" class="h-3 w-3" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isBooleanGridCell(item, col.actualColIdx)">
|
||||
<span class="flex w-full justify-center">
|
||||
<span :class="canEditCellItem(item, col.actualColIdx) ? 'cursor-pointer' : ''" @click="cycleBooleanGridCell(item, col.actualColIdx, $event)">{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ firstLineCellDisplayValue(formatCellCached(item.data[col.actualColIdx], col.actualColIdx)) }}</template>
|
||||
<div v-if="cellDetailButtonVisible(item.displayIndex, col.actualColIdx)" class="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<LightDropdownMenu
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ref, shallowRef, computed, nextTick, watch, getCurrentInstance, onActiv
|
|||
import * as api from "@/lib/backend/api";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { coerceDataGridCellValue, dataGridCellEditorText } from "@/lib/dataGrid/dataGridCellCoercion";
|
||||
import { nextBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
|
||||
import { focusDataGridEditorWithoutScrolling, preserveDataGridScrollPosition } from "@/lib/dataGrid/dataGridEditorFocus";
|
||||
import { normalizeDataGridSaveError } from "@/lib/dataGrid/dataGridSql";
|
||||
import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/dataGrid/gridRowStatus";
|
||||
|
|
@ -47,7 +48,7 @@ type CommitEditResult =
|
|||
|
||||
interface CommitEditOptions {
|
||||
promoteDraft?: boolean;
|
||||
explicitValue?: string | null;
|
||||
explicitValue?: CellValue;
|
||||
}
|
||||
|
||||
type GridScrollerRef =
|
||||
|
|
@ -792,6 +793,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
}
|
||||
}
|
||||
|
||||
async function cycleBooleanCellValue(rowId: number, col: number, nullable: boolean) {
|
||||
if (!editable.value || !canEditColumn(col)) return;
|
||||
const item = getRowItem(rowId);
|
||||
if (!item || item.isDeleted) return;
|
||||
if (!item.isNew && !item.isDraft && !canEditExistingRows.value) return;
|
||||
if (isSavingNewRow(item)) return;
|
||||
const newVal = nextBooleanCellValue(item.data[col], nullable);
|
||||
isCancelling = false;
|
||||
suppressNextBlurCommit = false;
|
||||
editingCell.value = { rowId, col };
|
||||
await commitEditAndMaybeAutoSave({ explicitValue: newVal });
|
||||
}
|
||||
|
||||
async function commitEditFromBlur(options: CommitEditOptions = {}) {
|
||||
if (suppressNextBlurCommit) {
|
||||
suppressNextBlurCommit = false;
|
||||
|
|
@ -1718,6 +1732,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
|||
startEdit,
|
||||
commitEdit,
|
||||
commitEditAndMaybeAutoSave,
|
||||
cycleBooleanCellValue,
|
||||
commitEditFromBlur,
|
||||
applyCellValue,
|
||||
restoreCellValue,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { firstLineCellDisplayValue, type CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { RowStatus } from "@/lib/dataGrid/gridRowStatus";
|
||||
import { DATA_GRID_DARK_SEARCH_COLORS, resolveDataGridPaintTheme, type DataGridPaintTheme } from "@/lib/dataGrid/dataGridPaintTheme";
|
||||
import { BOOLEAN_CHECKBOX_SIZE, isBooleanCheckboxValue, normalizeBooleanCellValue } from "@/lib/dataGrid/dataGridBooleanColumn";
|
||||
|
||||
export const CANVAS_DATA_GRID_ROW_HEIGHT = 26;
|
||||
|
||||
|
|
@ -64,6 +65,7 @@ export interface DrawCanvasDataGridOptions {
|
|||
searchMatchKeys: ReadonlySet<number>;
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export const BOOLEAN_CHECKBOX_SIZE = 13;
|
||||
|
||||
const MYSQL_BIT_BOOLEAN_DATABASE_TYPES = new Set<DatabaseType>(["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;
|
||||
}
|
||||
|
|
@ -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<ColumnInfo | undefined> {
|
||||
const columnsByName = new Map<string, ColumnInfo>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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\(/);
|
||||
});
|
||||
Loading…
Reference in New Issue