From 95519903b6035d9d5136cae2fa563367d025f9b8 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 10 May 2026 00:12:50 +0800 Subject: [PATCH] feat(grid): add DataGrip-style column filters --- src/components/grid/DataGrid.vue | 348 +++++++++++++++++++++++++++++-- src/i18n/locales/en.ts | 9 + src/i18n/locales/zh-CN.ts | 9 + 3 files changed, 348 insertions(+), 18 deletions(-) diff --git a/src/components/grid/DataGrid.vue b/src/components/grid/DataGrid.vue index 489c374cff..8a4cfd6e40 100644 --- a/src/components/grid/DataGrid.vue +++ b/src/components/grid/DataGrid.vue @@ -53,6 +53,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; @@ -238,6 +239,158 @@ const hasOrderByInput = computed(() => orderByInput.value.trim().length > 0); const whereFilterInput = ref(""); const hasWhereFilterInput = computed(() => whereFilterInput.value.trim().length > 0); +type LocalColumnFilterDraft = { + columnIndex: number; + values: Set; +}; + +const localColumnFilters = ref>>({}); +const localFilterOpenColumn = ref(null); +const localFilterSearch = ref(""); +const localFilterDraft = ref(null); + +function localFilterKey(value: CellValue): string { + if (value === null) return "__dbx_null__"; + if (typeof value === "boolean") return `bool:${value}`; + if (typeof value === "number") return `num:${value}`; + return `str:${String(value)}`; +} + +function localFilterLabel(value: CellValue): string { + return value === null ? "NULL" : formatCell(value); +} + +function localFilterActive(colIdx: number): boolean { + return !!localColumnFilters.value[colIdx]?.size; +} + +const localFilterCount = computed(() => Object.values(localColumnFilters.value).filter((values) => values.size).length); +const hasLocalColumnFilters = computed(() => localFilterCount.value > 0); + +function rowMatchesLocalColumnFilters(data: CellValue[]): boolean { + const activeEntries = Object.entries(localColumnFilters.value).filter(([, selected]) => selected.size > 0); + if (activeEntries.length === 0) return true; + return activeEntries.every(([columnIndex, selected]) => + selected.has(localFilterKey(data[Number(columnIndex)] ?? null)), + ); +} + +const localFilteredRows = computed(() => { + const rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex })); + if (!hasLocalColumnFilters.value) return rows; + return rows.filter(({ row, sourceIndex }) => rowMatchesLocalColumnFilters(rowDataWithChanges(row, sourceIndex))); +}); + +function buildLocalFilterOptions(columnIndex: number) { + const byKey = new Map(); + const addValue = (value: CellValue) => { + const key = localFilterKey(value); + const current = byKey.get(key); + if (current) { + current.count += 1; + } else { + byKey.set(key, { key, label: localFilterLabel(value), count: 1, value }); + } + }; + + for (const [sourceIndex, row] of props.result.rows.entries()) { + addValue(rowDataWithChanges(row, sourceIndex)[columnIndex] ?? null); + } + for (const row of newRows.value) { + addValue(row[columnIndex] ?? null); + } + + return [...byKey.values()].sort((a, b) => { + if (a.value === null && b.value !== null) return -1; + if (a.value !== null && b.value === null) return 1; + return a.label.localeCompare(b.label, undefined, { numeric: true, sensitivity: "base" }); + }); +} + +const localFilterAllOptions = computed(() => { + const columnIndex = localFilterDraft.value?.columnIndex; + if (columnIndex === undefined) return []; + return buildLocalFilterOptions(columnIndex); +}); + +const localFilterOptions = computed(() => { + const query = localFilterSearch.value.trim().toLowerCase(); + return localFilterAllOptions.value + .filter((option) => !query || option.label.toLowerCase().includes(query)) + .slice(0, 500); +}); + +const localFilterAllVisibleSelected = computed(() => { + const draft = localFilterDraft.value; + if (!draft || localFilterOptions.value.length === 0) return false; + return localFilterOptions.value.every((option) => draft.values.has(option.key)); +}); + +function openLocalFilter(colIdx: number) { + localFilterSearch.value = ""; + const allKeys = buildLocalFilterOptions(colIdx).map((option) => option.key); + localFilterDraft.value = { + columnIndex: colIdx, + values: new Set(localColumnFilters.value[colIdx] ?? allKeys), + }; + localFilterOpenColumn.value = colIdx; +} + +function closeLocalFilter() { + localFilterOpenColumn.value = null; + localFilterDraft.value = null; + localFilterSearch.value = ""; +} + +function toggleLocalFilterValue(key: string) { + const draft = localFilterDraft.value; + if (!draft) return; + const next = new Set(draft.values); + if (next.has(key)) next.delete(key); + else next.add(key); + localFilterDraft.value = { ...draft, values: next }; +} + +function toggleAllLocalFilterOptions() { + const draft = localFilterDraft.value; + if (!draft) return; + const visibleKeys = localFilterOptions.value.map((option) => option.key); + const next = new Set(draft.values); + if (localFilterAllVisibleSelected.value) { + visibleKeys.forEach((key) => next.delete(key)); + } else { + visibleKeys.forEach((key) => next.add(key)); + } + localFilterDraft.value = { ...draft, values: next }; +} + +function applyLocalFilter() { + const draft = localFilterDraft.value; + if (!draft) return; + const allKeys = new Set(localFilterAllOptions.value.map((option) => option.key)); + const next = { ...localColumnFilters.value }; + if (draft.values.size === 0 || draft.values.size === allKeys.size) { + delete next[draft.columnIndex]; + } else { + next[draft.columnIndex] = new Set(draft.values); + } + localColumnFilters.value = next; + closeLocalFilter(); + resetGridVerticalScroll(); +} + +function clearLocalFilter(colIdx?: number) { + if (colIdx === undefined) { + localColumnFilters.value = {}; + } else { + const next = { ...localColumnFilters.value }; + delete next[colIdx]; + localColumnFilters.value = next; + } + closeLocalFilter(); + resetGridVerticalScroll(); +} + function updateSuggestionPosition() { nextTick(() => { const input = searchInputRef.value; @@ -667,6 +820,13 @@ const columnVars = computed(() => { initColumnWidths(); watch(() => props.result.columns.length, initColumnWidths); +watch( + () => props.result, + () => { + localColumnFilters.value = {}; + closeLocalFilter(); + }, +); // --- Pagination --- const pageSize = ref(100); @@ -773,7 +933,7 @@ function onToolbarRollback() { } const sortedRows = computed(() => { - let rows = props.result.rows.map((row, sourceIndex) => ({ row, sourceIndex })); + let rows = localFilteredRows.value; if (clientSearchText.value) { const q = clientSearchText.value.toLowerCase(); rows = rows.filter(({ row, sourceIndex }) => { @@ -811,6 +971,7 @@ const displayItems = computed(() => { return { id: sourceIndex, sourceIndex, data, isNew: false, isDeleted, isDirtyCol, status }; }); newRows.value.forEach((row, i) => { + if (!rowMatchesLocalColumnFilters(row)) return; items.push({ id: -(i + 1), newIndex: i, @@ -824,7 +985,9 @@ const displayItems = computed(() => { return items.filter((item) => matchesRowStatusFilter(item.status, rowStatusFilter.value)); }); const hasVisibleRows = computed(() => displayItems.value.length > 0); -const hasActiveFilter = computed(() => !!clientSearchText.value || rowStatusFilter.value !== "all"); +const hasActiveFilter = computed( + () => !!clientSearchText.value || rowStatusFilter.value !== "all" || hasLocalColumnFilters.value, +); const totalFilterableRowCount = computed(() => props.result.rows.length + newRows.value.length); const emptyTitle = computed(() => (hasActiveFilter.value ? t("grid.noFilteredRows") : t("grid.noRows"))); const emptyDescription = computed(() => @@ -1888,6 +2051,17 @@ defineExpose({ {{ displayItems.length }}/{{ totalFilterableRowCount }} +