feat(grid): add column visibility filter
This commit is contained in:
parent
5cd9c9b722
commit
5549828e27
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -289,6 +289,10 @@ export default {
|
|||
count: "数量",
|
||||
applyFilter: "应用筛选",
|
||||
clearLocalFilters: "清除本地列筛选",
|
||||
columnVisibility: "字段筛选",
|
||||
columnVisibilityHint: "至少保留一列可见。",
|
||||
searchColumns: "搜索字段...",
|
||||
showAllColumns: "显示全部",
|
||||
moreValues: "还有 {count} 个值,输入关键词继续缩小范围",
|
||||
filterByValue: "筛选此值",
|
||||
filterExcludeValue: "排除此值",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
Loading…
Reference in New Issue