feat(grid): add column visibility filter

This commit is contained in:
t8y2 2026-05-18 11:45:12 +08:00
parent 5cd9c9b722
commit 5549828e27
7 changed files with 222 additions and 10 deletions

View File

@ -101,6 +101,11 @@ import {
} from "@/lib/columnFormatter";
import { isCancelSearchShortcut, isFocusSearchShortcut } from "@/lib/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGridScrollGutter";
import {
filterColumnVisibilityOptions,
nextHiddenColumnIndexes,
visibleColumnIndexesForFilter,
} from "@/lib/dataGridColumnVisibility";
import { useToast } from "@/composables/useToast";
import { useDataGridExport } from "@/composables/useDataGridExport";
@ -972,16 +977,40 @@ const rowStatusFilter = ref<RowStatusFilter>("all");
const gridRef = ref<HTMLDivElement>();
const headerRef = ref<HTMLDivElement>();
const gridScrollbarGutter = ref(0);
const visibleColumnIndexes = computed(() =>
const hiddenColumnIndexes = ref<Set<number>>(new Set());
const displayableColumnIndexes = computed(() =>
props.result.columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => !isHiddenGridColumn(props.databaseType, column, props.tableMeta?.primaryKeys ?? []))
.map(({ index }) => index),
);
const visibleColumnIndexes = computed(() =>
visibleColumnIndexesForFilter(displayableColumnIndexes.value, hiddenColumnIndexes.value),
);
const visibleColumns = computed(() => visibleColumnIndexes.value.map((index) => props.result.columns[index]));
const visibleRows = computed(() =>
props.result.rows.map((row) => visibleColumnIndexes.value.map((index) => row[index])),
);
const visibleColumnCount = computed(() => visibleColumnIndexes.value.length);
const displayableColumnCount = computed(() => displayableColumnIndexes.value.length);
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnCount.value);
function filteredColumnVisibilityOptions(query: string) {
const displayable = new Set(displayableColumnIndexes.value);
return filterColumnVisibilityOptions(props.result.columns, query).filter((option) => displayable.has(option.index));
}
function isColumnVisible(columnIndex: number): boolean {
return !hiddenColumnIndexes.value.has(columnIndex);
}
function toggleColumnVisibility(columnIndex: number) {
hiddenColumnIndexes.value = nextHiddenColumnIndexes({
columnIndex,
hiddenIndexes: hiddenColumnIndexes.value,
totalColumns: displayableColumnCount.value,
});
}
function showAllColumns() {
hiddenColumnIndexes.value = new Set();
}
const firstVisibleColumnIndex = computed(() => visibleColumnIndexes.value[0] ?? 0);
function actualColumnIndex(visibleColumnIndex: number): number {
return visibleColumnIndexes.value[visibleColumnIndex] ?? visibleColumnIndex;
@ -1025,6 +1054,7 @@ watch(
() => props.result,
() => {
localColumnFilters.value = {};
hiddenColumnIndexes.value = new Set();
closeLocalFilter();
},
);
@ -2019,14 +2049,6 @@ function scrollTransposeRecordIntoView(rowIndex: number) {
});
}
function openTranspose(rowIndex: number) {
transposeRowIndex.value = rowIndex;
showTranspose.value = true;
showCellDetail.value = false;
nextTick(updateTransposeViewport);
scrollTransposeRecordIntoView(rowIndex);
}
function openContextTranspose() {
if (!contextCell.value) return;
const next = nextContextTransposeState({
@ -2370,6 +2392,13 @@ defineExpose({
showTableInfo,
toggleTableInfo,
focusSearch,
visibleColumnCount,
displayableColumnCount,
hiddenColumnCount,
filteredColumnVisibilityOptions,
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
});
</script>

View File

@ -1,10 +1,22 @@
<script setup lang="ts">
import { computed, ref, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { Loader2, Square, Bot, Table2, GitBranch, BarChart3, TableProperties } from "lucide-vue-next";
import {
Check,
Columns3,
Loader2,
Search,
Square,
Bot,
Table2,
GitBranch,
BarChart3,
TableProperties,
} from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
@ -60,6 +72,10 @@ const columnInfoColumns = ref<ColumnInfo[]>([]);
const columnInfoLoading = ref(false);
const columnInfoError = ref<string | undefined>(undefined);
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(
() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? [],
);
const redisKeyBrowserRef = ref<InstanceType<typeof RedisKeyBrowser>>();
const objectBrowserRef = ref<InstanceType<typeof ObjectBrowser>>();
@ -387,6 +403,87 @@ defineExpose({ focusSearch });
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
</span>
<Popover v-if="activeTab.result?.columns.length">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-5 text-xs px-1.5 shrink-0"
:class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }"
>
<Columns3 class="h-3.5 w-3.5" />
{{ t("grid.columnVisibility") }}
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums">
{{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
class="w-72 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl"
@click.stop
@keydown.stop
>
<div class="border-b bg-muted/40 px-3 py-2">
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-semibold">{{ t("grid.columnVisibility") }}</div>
<div class="text-[11px] text-muted-foreground tabular-nums">
{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}
</div>
</div>
</div>
<div class="flex items-center gap-2 border-b px-3 py-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<input
v-model="columnVisibilitySearch"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.searchColumns')"
/>
</div>
<div class="max-h-72 overflow-auto py-1">
<button
v-for="option in columnVisibilityOptions"
:key="`${option.index}:${option.column}`"
type="button"
class="grid w-full grid-cols-[1.75rem_minmax(0,1fr)] items-center px-3 py-1.5 text-left text-sm hover:bg-accent"
@click="dataGridRef?.toggleColumnVisibility(option.index)"
>
<span
class="flex h-5 w-5 items-center justify-center rounded border"
:class="
dataGridRef?.isColumnVisible(option.index)
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-transparent'
"
>
<Check class="h-3.5 w-3.5 stroke-[3]" />
</span>
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
</button>
<div
v-if="columnVisibilityOptions.length === 0"
class="px-3 py-8 text-center text-sm text-muted-foreground"
>
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-3 py-2">
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0"
@click="dataGridRef?.showAllColumns()"
>
{{ t("grid.showAllColumns") }}
</Button>
</div>
</PopoverContent>
</Popover>
<Button
v-if="activeTab.tableMeta && activeTab.connectionId"
variant="ghost"

View File

@ -293,6 +293,10 @@ export default {
count: "Count",
applyFilter: "Apply Filter",
clearLocalFilters: "Clear local column filters",
columnVisibility: "Columns",
columnVisibilityHint: "At least one column stays visible.",
searchColumns: "Search columns...",
showAllColumns: "Show all",
moreValues: "{count} more values, keep typing to narrow results",
filterByValue: "Filter by This Value",
filterExcludeValue: "Exclude This Value",

View File

@ -269,6 +269,10 @@ export default {
count: "Conteo",
applyFilter: "Aplicar filtro",
clearLocalFilters: "Limpiar filtros locales de columnas",
columnVisibility: "Columnas",
columnVisibilityHint: "Al menos una columna permanece visible.",
searchColumns: "Buscar columnas...",
showAllColumns: "Mostrar todo",
moreValues: "{count} valores más, sigue escribiendo para acotar los resultados",
filterByValue: "Filtrar por este valor",
filterExcludeValue: "Excluir este valor",

View File

@ -289,6 +289,10 @@ export default {
count: "数量",
applyFilter: "应用筛选",
clearLocalFilters: "清除本地列筛选",
columnVisibility: "字段筛选",
columnVisibilityHint: "至少保留一列可见。",
searchColumns: "搜索字段...",
showAllColumns: "显示全部",
moreValues: "还有 {count} 个值,输入关键词继续缩小范围",
filterByValue: "筛选此值",
filterExcludeValue: "排除此值",

View File

@ -0,0 +1,35 @@
export interface ColumnVisibilityOption {
column: string;
index: number;
}
export function filterColumnVisibilityOptions(columns: string[], query: string): ColumnVisibilityOption[] {
const normalizedQuery = query.trim().toLowerCase();
return columns
.map((column, index) => ({ column, index }))
.filter(({ column }) => !normalizedQuery || column.toLowerCase().includes(normalizedQuery));
}
export function visibleColumnIndexesForFilter(
availableIndexes: number[],
hiddenIndexes: ReadonlySet<number>,
): number[] {
const visibleIndexes = availableIndexes.filter((index) => !hiddenIndexes.has(index));
return visibleIndexes.length > 0 ? visibleIndexes : availableIndexes;
}
export function nextHiddenColumnIndexes(options: {
columnIndex: number;
hiddenIndexes: ReadonlySet<number>;
totalColumns: number;
}): Set<number> {
const next = new Set(options.hiddenIndexes);
if (next.has(options.columnIndex)) {
next.delete(options.columnIndex);
return next;
}
if (options.totalColumns - next.size <= 1) return next;
next.add(options.columnIndex);
return next;
}

View File

@ -0,0 +1,39 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
filterColumnVisibilityOptions,
nextHiddenColumnIndexes,
visibleColumnIndexesForFilter,
} from "../../apps/desktop/src/lib/dataGridColumnVisibility.ts";
test("filters column visibility options by trimmed case-insensitive text", () => {
const options = filterColumnVisibilityOptions(["id", "created_at", "CustomerName"], " NAME ");
assert.deepEqual(options, [{ column: "CustomerName", index: 2 }]);
});
test("removes hidden indexes from visible columns", () => {
const indexes = visibleColumnIndexesForFilter([0, 1, 2, 3], new Set([1, 3]));
assert.deepEqual(indexes, [0, 2]);
});
test("keeps the last visible column when toggling visibility", () => {
const hidden = nextHiddenColumnIndexes({
columnIndex: 0,
hiddenIndexes: new Set([1, 2]),
totalColumns: 3,
});
assert.deepEqual([...hidden].sort(), [1, 2]);
});
test("shows a hidden column again when toggled", () => {
const hidden = nextHiddenColumnIndexes({
columnIndex: 1,
hiddenIndexes: new Set([1, 2]),
totalColumns: 4,
});
assert.deepEqual([...hidden].sort(), [2]);
});