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
156 changes: 156 additions & 0 deletions apps/desktop/src/components/grid/DataGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -5341,6 +5344,50 @@ function transposeHeaderIsCurrentMatch(fieldIndex: number): boolean {
return m.kind === "column" && m.col === fieldIndex;
}

const columnHighlightRules = ref<Map<number, ColumnHighlightRule>>(new Map());

watch(
() => props.result,
() => {
clearAllColumnHighlights(columnHighlightRules.value);
},
);

function columnHighlightMatchesFor(rules: ReadonlyMap<number, ColumnHighlightRule>) {
return computeColumnHighlightMatchKeys({
rows: displayItems.value,
rules,
isNullValue: (value) => value === null || (usesMongoDocumentGridValues.value && value === MONGO_DOCUMENT_GRID_NULL),
});
}

const columnHighlightMatches = computed(() => {
// Empty rules must short-circuit before displayItems: the derived key sets are
// read on every canvas draw, and row materialization is paid per invalidation
// (#8524 precedent).
if (columnHighlightRules.value.size === 0) {
return { duplicateKeys: new Set<number>(), nullKeys: new Set<number>() };
}
return columnHighlightMatchesFor(columnHighlightRules.value);
});

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);
}
Expand Down Expand Up @@ -8015,6 +8062,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,
Expand Down Expand Up @@ -12492,6 +12541,106 @@ 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) {
// Count only the toggled columns so a toast does not report matches that
// belong to other columns' active highlight rules.
const targetRules = new Map<number, ColumnHighlightRule>();
for (const idx of indexes) {
const rule = newRules.get(idx);
if (rule) targetRules.set(idx, rule);
}
const count = columnHighlightMatchesFor(targetRules).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) {
// Scope the toast count to the toggled columns, mirroring the duplicates toast.
const targetRules = new Map<number, ColumnHighlightRule>();
for (const idx of indexes) {
const rule = newRules.get(idx);
if (rule) targetRules.set(idx, rule);
}
const count = columnHighlightMatchesFor(targetRules).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;
Expand Down Expand Up @@ -12749,6 +12898,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
showAllColumnsMenu: showAllColumns,
},
filterSubmenu: filterSubmenu(),
highlightSubmenu: highlightSubmenu(),
}),
createDataGridCellContextMenuItems({
hasCell: !!contextCell.value,
Expand Down Expand Up @@ -13396,6 +13546,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,
Expand Down Expand Up @@ -13584,6 +13736,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)"
Expand Down Expand Up @@ -14310,6 +14464,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',
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/components/grid/DataGridColumnHeader.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { CSSProperties, HTMLAttributes } from "vue";
import { Copy, KeyRound, Hash } from "@lucide/vue";
import { Copy, KeyRound, Hash, Highlighter } from "@lucide/vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { columnIndexColorClass, type ColumnIndexKind } from "@/lib/dataGrid/dataGridColumnIndexIcon";

Expand Down Expand Up @@ -40,6 +40,9 @@ const props = defineProps<{
/** 该列当前展示值应用了格式化规则。 */
formatterActive?: boolean;
formatterLabel?: string;
/** 该列当前启用了重复值或空值高亮。 */
highlightActive?: boolean;
highlightLabel?: string;
}>();

function columnIndexText(kind: ColumnIndexKind): string {
Expand Down Expand Up @@ -119,6 +122,9 @@ const emit = defineEmits<{
</template>
</LightTooltip>
<span v-if="formatterActive" data-column-formatter-indicator class="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm bg-violet-600 text-[10px] font-bold leading-none text-white shadow-sm dark:bg-violet-500" :title="formatterLabel" :aria-label="formatterLabel">F</span>
<span v-if="highlightActive" data-column-highlight-indicator class="inline-flex h-4 w-4 shrink-0 items-center justify-center text-amber-500 dark:text-amber-400" :title="highlightLabel" :aria-label="highlightLabel">
<Highlighter class="h-3 w-3" />
</span>
<span data-column-header-actions class="contents"><slot name="actions" /></span>
</span>
<div data-column-resize-handle class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="emit('resizeStart', $event)" @click.stop.prevent @dblclick.stop="emit('autoFit')" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom

import { createApp, h, nextTick, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import DataGridColumnHeader from "../DataGridColumnHeader.vue";

const mountedApps: Array<{ app: App; host: HTMLElement }> = [];

function mountHeader(props: Record<string, unknown>) {
const host = document.createElement("div");
document.body.appendChild(host);
const app = createApp({
render() {
return h(DataGridColumnHeader, {
actualColumnIndex: 0,
visibleColumnIndex: 0,
name: "test_col",
type: "VARCHAR",
width: 120,
isSorted: false,
sortMode: null,
copyColumnNameLabel: "Copy",
columnNameLabel: "Column",
columnTypeLabel: "Type",
columnCommentLabel: "Comment",
columnIndexLabel: "Index",
columnPrimaryIndexLabel: "PK",
columnUniqueIndexLabel: "Unique",
columnRegularIndexLabel: "Regular",
...props,
});
},
});
app.mount(host);
mountedApps.push({ app, host });
return host;
}

afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
document.body.innerHTML = "";
});

describe("DataGridColumnHeader highlight indicator", () => {
it("does not render highlight indicator when highlightActive is false or omitted", () => {
const host = mountHeader({ highlightActive: false });
const indicator = host.querySelector("[data-column-highlight-indicator]");
expect(indicator).toBeNull();
});

it("renders highlight indicator with title and aria-label when highlightActive is true", async () => {
const host = mountHeader({
highlightActive: true,
highlightLabel: "Highlight Active",
});
await nextTick();
const indicator = host.querySelector("[data-column-highlight-indicator]");
expect(indicator).not.toBeNull();
expect(indicator?.getAttribute("title")).toBe("Highlight Active");
expect(indicator?.getAttribute("aria-label")).toBe("Highlight Active");
});
});
9 changes: 9 additions & 0 deletions apps/desktop/src/i18n/locales/az.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2103,6 +2103,15 @@ export default withEnglishFallback({
filterIsNull: "NULL dəyərləri göstər",
filterIsNotNull: "NULL olmayan dəyərləri göstər",
clearFilter: "Süzgəci təmizlə",
highlight: "Vurğula",
highlightDuplicates: "Təkrarlanan Dəyərləri Vurğula",
highlightNulls: "NULL Dəyərlərini Vurğula",
clearHighlight: "Vurğulamaları Təmizlə",
highlightActive: "Vurğulama Aktivdir",
highlightDuplicatesFound: "{count} təkrarlanan dəyər vurğulandı",
highlightDuplicatesNoneFound: "Təkrarlanan dəyər tapılmadı",
highlightNullsFound: "{count} NULL dəyər tapıldı",
highlightNullsNoneFound: "NULL dəyər tapılmadı",
sort: "Sırala",
sortAscending: "Artan sıra ilə sırala",
sortDescending: "Azalan sıra ilə sırala",
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2516,6 +2516,15 @@ export default {
filterIsNull: "Show NULL Values",
filterIsNotNull: "Show Non-NULL Values",
clearFilter: "Clear Filter",
highlight: "Highlight",
highlightDuplicates: "Highlight Duplicate Values",
highlightNulls: "Highlight NULL Values",
clearHighlight: "Clear Highlights",
highlightActive: "Highlight Active",
highlightDuplicatesFound: "Highlighted {count} duplicate values",
highlightDuplicatesNoneFound: "No duplicate values found",
highlightNullsFound: "Highlighted {count} NULL values",
highlightNullsNoneFound: "No NULL values found",
sort: "Sort",
sortAscending: "Sort Ascending",
sortDescending: "Sort Descending",
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2356,6 +2356,15 @@ export default withEnglishFallback({
filterIsNull: "Mostrar valores NULL",
filterIsNotNull: "Mostrar valores no NULL",
clearFilter: "Limpiar filtro",
highlight: "Resaltar",
highlightDuplicates: "Resaltar valores duplicados",
highlightNulls: "Resaltar valores NULL",
clearHighlight: "Borrar resaltados",
highlightActive: "Resaltado activo",
highlightDuplicatesFound: "Se resaltaron {count} valores duplicados",
highlightDuplicatesNoneFound: "No se encontraron valores duplicados",
highlightNullsFound: "Se resaltaron {count} valores NULL",
highlightNullsNoneFound: "No se encontraron valores NULL",
sort: "Ordenar",
sortAscending: "Orden ascendente",
sortDescending: "Orden descendente",
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/i18n/locales/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2375,6 +2375,15 @@ export default withEnglishFallback({
filterIsNull: "Tampilkan Nilai NULL",
filterIsNotNull: "Tampilkan Nilai Non-NULL",
clearFilter: "Bersihkan Filter",
highlight: "Sorot",
highlightDuplicates: "Sorot Nilai Duplikat",
highlightNulls: "Sorot Nilai NULL",
clearHighlight: "Hapus Sorotan",
highlightActive: "Sorotan Aktif",
highlightDuplicatesFound: "{count} nilai duplikat disorot",
highlightDuplicatesNoneFound: "Tidak ditemukan nilai duplikat",
highlightNullsFound: "{count} nilai NULL disorot",
highlightNullsNoneFound: "Tidak ditemukan nilai NULL",
sort: "Urutkan",
sortAscending: "Urutkan Naik",
sortDescending: "Urutkan Turun",
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/i18n/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2250,6 +2250,15 @@ export default withEnglishFallback({
filterIsNull: "Mostra Valori NULL",
filterIsNotNull: "Mostra Valori Non-NULL",
clearFilter: "Cancella Filtro",
highlight: "Evidenzia",
highlightDuplicates: "Evidenzia valori duplicati",
highlightNulls: "Evidenzia valori NULL",
clearHighlight: "Cancella evidenziazione",
highlightActive: "Evidenziazione attiva",
highlightDuplicatesFound: "Evidenziati {count} valori duplicati",
highlightDuplicatesNoneFound: "Nessun valore duplicato trovato",
highlightNullsFound: "Evidenziati {count} valori NULL",
highlightNullsNoneFound: "Nessun valore NULL trovato",
sort: "Ordina",
sortAscending: "Ordina in modo crescente",
sortDescending: "Ordina in modo decrescente",
Expand Down
Loading
Loading