From e3c1057b353fa38c1339c18a98a7c496c150354c Mon Sep 17 00:00:00 2001 From: lxk955 <164191365+lxk955@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:24:46 +0000 Subject: [PATCH 1/2] feat(grid): support highlighting duplicate and null values in column context menu --- apps/desktop/src/components/grid/DataGrid.vue | 133 ++++++++++++++++ .../components/grid/DataGridColumnHeader.vue | 8 +- .../DataGridColumnHeader.highlight.spec.ts | 65 ++++++++ apps/desktop/src/i18n/locales/az.ts | 9 ++ apps/desktop/src/i18n/locales/en.ts | 9 ++ apps/desktop/src/i18n/locales/es.ts | 9 ++ apps/desktop/src/i18n/locales/id.ts | 9 ++ apps/desktop/src/i18n/locales/it.ts | 9 ++ apps/desktop/src/i18n/locales/ja.ts | 9 ++ apps/desktop/src/i18n/locales/ko.ts | 9 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 9 ++ apps/desktop/src/i18n/locales/ru.ts | 9 ++ apps/desktop/src/i18n/locales/tr.ts | 9 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 9 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 9 ++ .../canvasDataGridRendererFrozen.spec.ts | 15 ++ .../dataGrid/dataGridColumnHighlight.spec.ts | 134 ++++++++++++++++ .../dataGrid/dataGridContextMenu.spec.ts | 132 +++++++++++++++- .../lib/dataGrid/canvasDataGridRenderer.ts | 21 ++- .../lib/dataGrid/dataGridColumnHighlight.ts | 143 ++++++++++++++++++ .../src/lib/dataGrid/dataGridContextMenu.ts | 42 +++++ 21 files changed, 798 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridColumnHeader.highlight.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridColumnHighlight.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridColumnHighlight.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 262b2a0b91..79d82e112b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -61,6 +61,7 @@ import { AlertTriangle, FileSpreadsheet, Globe2, + Highlighter, } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -302,11 +303,13 @@ import { createDataGridCompactColumnActionItems, createDataGridContextMenuItems, createDataGridFilterSubmenu, + createDataGridHighlightSubmenu, createDataGridRowContextMenuItems, createDataGridSortMenuItems, dataGridSelectedSortMenuValue, type DataGridColumnSortState, } from "@/lib/dataGrid/dataGridContextMenu"; +import { clearAllColumnHighlights, clearColumnHighlight, computeColumnHighlightMatchKeys, hasColumnHighlight, isColumnDuplicateHighlightActive, isColumnNullHighlightActive, setColumnDuplicateHighlight, setColumnNullHighlight, type ColumnHighlightRule } from "@/lib/dataGrid/dataGridColumnHighlight"; import { buildColumnForeignKeyMap, combineForeignKeyConditions, foreignKeyAssociationCells, foreignKeyNavigationTarget, foreignKeySourceColumnName, type ForeignKeyAssociation } from "@/lib/dataGrid/dataGridForeignKeyNavigation"; import { collectForeignKeyDisplayValues, @@ -5341,6 +5344,40 @@ function transposeHeaderIsCurrentMatch(fieldIndex: number): boolean { return m.kind === "column" && m.col === fieldIndex; } +const columnHighlightRules = ref>(new Map()); + +watch( + () => props.result, + () => { + clearAllColumnHighlights(columnHighlightRules.value); + }, +); + +const columnHighlightMatches = computed(() => { + return computeColumnHighlightMatchKeys({ + rows: displayItems.value, + rules: columnHighlightRules.value, + isNullValue: (value) => value === null || (usesMongoDocumentGridValues.value && value === MONGO_DOCUMENT_GRID_NULL), + }); +}); + +const duplicateHighlightKeys = computed(() => columnHighlightMatches.value.duplicateKeys); +const nullHighlightKeys = computed(() => columnHighlightMatches.value.nullKeys); + +function columnHasHighlight(colIdx: number): boolean { + return hasColumnHighlight(columnHighlightRules.value, colIdx); +} + +function cellIsDuplicateHighlight(displayRow: number, col: number): boolean { + if (isScrolling.value) return false; + return duplicateHighlightKeys.value.has(dataGridSearchMatchKey(displayRow, col)); +} + +function cellIsNullHighlight(displayRow: number, col: number): boolean { + if (isScrolling.value) return false; + return nullHighlightKeys.value.has(dataGridSearchMatchKey(displayRow, col)); +} + function navigateMatch(delta: number) { dataGridSearch.navigateMatch(delta); } @@ -8015,6 +8052,8 @@ function drawCanvasGrid() { editingCell: editingCell.value, searchMatchKeys: searchMatchSet.value, currentSearchMatch: currentSearchMatch.value, + duplicateHighlightKeys: duplicateHighlightKeys.value, + nullHighlightKeys: nullHighlightKeys.value, formatCell: (value, columnIndex, row) => formatCellCached(visibleLargeValuePreviewValue(row, columnIndex, value), columnIndex, largeValueOriginalBytes(row, columnIndex)), isNullValue: (value) => value === null || (usesMongoDocumentGridValues.value && value === MONGO_DOCUMENT_GRID_NULL), newRowCellPlaceholder, @@ -12492,6 +12531,93 @@ function filterSubmenu(): ContextMenuItem { }); } +function targetHighlightColumnIndexes(): number[] { + if (contextHeaderColumnIndex.value !== null) { + const visibleIdx = contextHeaderVisibleColIdx.value; + if (visibleIdx !== null && selectedColumnIndexes.value.has(visibleIdx) && selectedColumnIndexes.value.size > 1) { + return [...selectedColumnIndexes.value].map(actualColumnIndex); + } + return [contextHeaderColumnIndex.value]; + } + if (contextCell.value && contextCell.value.col >= 0) { + const visibleIdx = visibleColumnIndexes.value.indexOf(contextCell.value.col); + if (visibleIdx >= 0 && selectedColumnIndexes.value.has(visibleIdx) && selectedColumnIndexes.value.size > 1) { + return [...selectedColumnIndexes.value].map(actualColumnIndex); + } + return [contextCell.value.col]; + } + return []; +} + +function toggleTargetColumnDuplicateHighlight() { + const indexes = targetHighlightColumnIndexes(); + if (indexes.length === 0) return; + const anyActive = indexes.some((idx) => isColumnDuplicateHighlightActive(columnHighlightRules.value, idx)); + const newRules = new Map(columnHighlightRules.value); + for (const idx of indexes) { + setColumnDuplicateHighlight(newRules, idx, !anyActive); + } + columnHighlightRules.value = newRules; + if (!anyActive) { + const count = columnHighlightMatches.value.duplicateKeys.size; + if (count > 0) { + toast(t("grid.highlightDuplicatesFound", { count })); + } else { + toast(t("grid.highlightDuplicatesNoneFound")); + } + } +} + +function toggleTargetColumnNullHighlight() { + const indexes = targetHighlightColumnIndexes(); + if (indexes.length === 0) return; + const anyActive = indexes.some((idx) => isColumnNullHighlightActive(columnHighlightRules.value, idx)); + const newRules = new Map(columnHighlightRules.value); + for (const idx of indexes) { + setColumnNullHighlight(newRules, idx, !anyActive); + } + columnHighlightRules.value = newRules; + if (!anyActive) { + const count = columnHighlightMatches.value.nullKeys.size; + if (count > 0) { + toast(t("grid.highlightNullsFound", { count })); + } else { + toast(t("grid.highlightNullsNoneFound")); + } + } +} + +function clearTargetColumnHighlight() { + const indexes = targetHighlightColumnIndexes(); + if (indexes.length === 0) return; + const newRules = new Map(columnHighlightRules.value); + for (const idx of indexes) { + clearColumnHighlight(newRules, idx); + } + columnHighlightRules.value = newRules; +} + +function highlightSubmenu(): ContextMenuItem { + const indexes = targetHighlightColumnIndexes(); + const hasDup = indexes.some((idx) => isColumnDuplicateHighlightActive(columnHighlightRules.value, idx)); + const hasNull = indexes.some((idx) => isColumnNullHighlightActive(columnHighlightRules.value, idx)); + return createDataGridHighlightSubmenu({ + label: t("grid.highlight"), + icon: Highlighter, + labels: { + duplicates: t("grid.highlightDuplicates"), + nulls: t("grid.highlightNulls"), + clear: t("grid.clearHighlight"), + }, + hasDuplicatesActive: hasDup, + hasNullsActive: hasNull, + canClear: hasDup || hasNull, + toggleDuplicates: toggleTargetColumnDuplicateHighlight, + toggleNulls: toggleTargetColumnNullHighlight, + clear: clearTargetColumnHighlight, + }); +} + function buildExtractorContextItems(destination: "copy" | "export" = "copy"): ContextMenuItem[] { const items: ContextMenuItem[] = []; let separatorPending = false; @@ -12749,6 +12875,7 @@ const gridContextMenuItems = computed(() => { showAllColumnsMenu: showAllColumns, }, filterSubmenu: filterSubmenu(), + highlightSubmenu: highlightSubmenu(), }), createDataGridCellContextMenuItems({ hasCell: !!contextCell.value, @@ -13396,6 +13523,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val !transposeCellIsSelected(cell.recordIndex, cell.valueIndex), 'bg-primary/15': transposeRecordUsesActiveHighlight(cell.recordIndex) && !transposeRecordUsesSelectionVisual(cell.recordIndex) && !displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex] && !transposeCellIsSelected(cell.recordIndex, cell.valueIndex), 'bg-yellow-500/10 cell-dirty': displayItems[cell.recordIndex]?.isDirtyCol[cell.valueIndex], + 'bg-amber-200/60 dark:bg-amber-500/25': cellIsDuplicateHighlight(cell.recordIndex, cell.valueIndex), + 'bg-sky-200/60 dark:bg-sky-500/25': cellIsNullHighlight(cell.recordIndex, cell.valueIndex), 'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(cell.recordIndex, cell.valueIndex), 'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(cell.recordIndex, cell.valueIndex), 'cursor-text': !isScrolling, @@ -13584,6 +13713,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val :column-index-kind="showIndexIndicatorsInHeader ? columnIndexMap.get(columnIndexNameKey(col.name)) : undefined" :formatter-active="columnHasFormatter(col.actualColIdx)" :formatter-label="t('grid.columnFormatterActive')" + :highlight-active="columnHasHighlight(col.actualColIdx)" + :highlight-label="t('grid.highlightActive')" @pointerdown="startColumnHeaderDrag(col.visibleColIdx, $event)" @click-capture="onHeaderClickCapture" @click="onHeaderClick(col.visibleColIdx, $event)" @@ -14310,6 +14441,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val 'crosshair-column': !!crosshairTarget?.columnCrosshair && crosshairTarget.visibleColIdx === col.visibleColIdx && !item.isDeleted, 'cell-search-match': cellIsSearchMatch(item.displayIndex, col.actualColIdx), 'cell-current-search-match': cellIsCurrentMatch(item.displayIndex, col.actualColIdx), + 'bg-amber-200/60 dark:bg-amber-500/25': cellIsDuplicateHighlight(item.displayIndex, col.actualColIdx), + 'bg-sky-200/60 dark:bg-sky-500/25': cellIsNullHighlight(item.displayIndex, col.actualColIdx), 'bg-yellow-200/60 dark:bg-yellow-500/20': cellIsSearchMatch(item.displayIndex, col.actualColIdx), 'ring-2 ring-inset ring-yellow-500 bg-yellow-300/60 dark:bg-yellow-500/40': cellIsCurrentMatch(item.displayIndex, col.actualColIdx), 'tabular-nums': typeof item.data[col.actualColIdx] === 'number', diff --git a/apps/desktop/src/components/grid/DataGridColumnHeader.vue b/apps/desktop/src/components/grid/DataGridColumnHeader.vue index 5d2ec4761a..bcb209331d 100644 --- a/apps/desktop/src/components/grid/DataGridColumnHeader.vue +++ b/apps/desktop/src/components/grid/DataGridColumnHeader.vue @@ -1,6 +1,6 @@