feat(grid): hide all-null columns

This commit is contained in:
t8y2 2026-06-03 01:12:03 +08:00
parent 0dbb30f614
commit 4ce010369b
8 changed files with 244 additions and 1 deletions

View File

@ -170,9 +170,12 @@ import {
resultPageSizeMenuOptions,
} from "@/lib/paginationPageSize";
import {
allNullColumnIndexes,
filterColumnVisibilityOptions,
hiddenColumnIndexesWithAllNullColumns,
invertedHiddenColumnIndexes,
nextHiddenColumnIndexes,
removeAutoHiddenColumnIndexes,
visibleColumnIndexesForFilter,
} from "@/lib/dataGridColumnVisibility";
@ -1436,6 +1439,8 @@ const gridRef = ref<HTMLDivElement>();
const headerRef = ref<HTMLDivElement>();
const gridScrollbarGutter = ref(0);
const hiddenColumnIndexes = ref<Set<number>>(new Set());
const nullColumnsHidden = ref(false);
const autoHiddenNullColumnIndexes = ref<Set<number>>(new Set());
const highlightedColumnIndex = ref<number | null>(null);
let highlightedColumnTimer = 0;
const displayableColumnIndexes = computed(() =>
@ -1455,6 +1460,13 @@ const visibleSourceColumns = computed(() => {
const visibleColumnCount = computed(() => visibleColumnIndexes.value.length);
const displayableColumnCount = computed(() => displayableColumnIndexes.value.length);
const hiddenColumnCount = computed(() => displayableColumnCount.value - visibleColumnCount.value);
const allNullColumnIndexesForResult = computed(() =>
allNullColumnIndexes(props.result.rows, displayableColumnIndexes.value),
);
const allNullColumnCount = computed(() => allNullColumnIndexesForResult.value.length);
const canToggleAllNullColumns = computed(
() => nullColumnsHidden.value || (allNullColumnCount.value > 0 && displayableColumnCount.value > 1),
);
function filteredColumnVisibilityOptions(query: string) {
const displayable = new Set(displayableColumnIndexes.value);
return filterColumnVisibilityOptions(props.result.columns, query).filter((option) => displayable.has(option.index));
@ -1475,6 +1487,40 @@ function showAllColumns() {
function invertColumnVisibility() {
hiddenColumnIndexes.value = invertedHiddenColumnIndexes(displayableColumnIndexes.value, hiddenColumnIndexes.value);
}
function showAllNullColumns() {
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(
hiddenColumnIndexes.value,
autoHiddenNullColumnIndexes.value,
);
autoHiddenNullColumnIndexes.value = new Set();
nullColumnsHidden.value = false;
}
function hideAllNullColumns() {
const next = hiddenColumnIndexesWithAllNullColumns({
availableIndexes: displayableColumnIndexes.value,
hiddenIndexes: hiddenColumnIndexes.value,
allNullIndexes: new Set(allNullColumnIndexesForResult.value),
});
hiddenColumnIndexes.value = next.hiddenIndexes;
autoHiddenNullColumnIndexes.value = next.autoHiddenIndexes;
nullColumnsHidden.value = next.autoHiddenIndexes.size > 0;
}
function toggleAllNullColumns() {
if (nullColumnsHidden.value) {
showAllNullColumns();
} else {
hideAllNullColumns();
}
}
watch(allNullColumnIndexesForResult, () => {
if (!nullColumnsHidden.value) return;
hiddenColumnIndexes.value = removeAutoHiddenColumnIndexes(
hiddenColumnIndexes.value,
autoHiddenNullColumnIndexes.value,
);
autoHiddenNullColumnIndexes.value = new Set();
hideAllNullColumns();
});
const firstVisibleColumnIndex = computed(() => visibleColumnIndexes.value[0] ?? 0);
function actualColumnIndex(visibleColumnIndex: number): number {
return visibleColumnIndexes.value[visibleColumnIndex] ?? visibleColumnIndex;
@ -1690,6 +1736,8 @@ watch(
() => {
localColumnFilters.value = {};
hiddenColumnIndexes.value = new Set();
nullColumnsHidden.value = false;
autoHiddenNullColumnIndexes.value = new Set();
closeLocalFilter();
},
);
@ -4896,6 +4944,10 @@ defineExpose({
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
nullColumnsHidden,
allNullColumnCount,
canToggleAllNullColumns,
toggleAllNullColumns,
openCellDetailSearch,
});

View File

@ -15,6 +15,7 @@ import {
ChevronDown,
ChevronUp,
RefreshCcw,
Wrench,
} from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
@ -55,6 +56,10 @@ type DataGridHandle = {
toggleColumnVisibility: (columnIndex: number) => void;
showAllColumns: () => void;
invertColumnVisibility: () => void;
nullColumnsHidden: boolean;
allNullColumnCount: number;
canToggleAllNullColumns: boolean;
toggleAllNullColumns: () => void;
showDdl: boolean;
toggleDdl: () => void;
};
@ -412,11 +417,56 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
<BarChart3 class="h-3.5 w-3.5" />
{{ t("chart.title") }}
</Button>
<Popover v-if="activeOutputView === 'result' && activeTab.result">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="ml-auto h-6 w-7 shrink-0 text-foreground hover:bg-accent"
:class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }"
:title="t('grid.viewOptions')"
:aria-label="t('grid.viewOptions')"
>
<Wrench class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
class="w-max min-w-44 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="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<label
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent"
:class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }"
>
<input
type="checkbox"
class="h-3.5 w-3.5 shrink-0 accent-primary"
:checked="!!dataGridRef?.nullColumnsHidden"
:disabled="!dataGridRef?.canToggleAllNullColumns"
@change="dataGridRef?.toggleAllNullColumns()"
/>
<span class="min-w-0 flex items-center gap-1 font-medium">
{{ t("grid.hideNullColumns") }}
<span
v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0"
class="text-muted-foreground tabular-nums"
>
({{ dataGridRef?.allNullColumnCount }})
</span>
</span>
</label>
</PopoverContent>
</Popover>
<Button
v-if="activeOutputView === 'result' && activeTab.result"
variant="ghost"
size="sm"
class="ml-auto h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
class="h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
:disabled="activeTab.isExecuting"
@click="refreshData"
>
@ -660,6 +710,48 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
>
<TableProperties class="h-3.5 w-3.5" /> {{ t("grid.tableInfo") }}
</Button>
<Popover v-if="activeTab.result?.columns.length">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-6 w-7 shrink-0 text-foreground hover:bg-accent"
:class="{ 'bg-accent text-foreground': dataGridRef?.nullColumnsHidden }"
:title="t('grid.viewOptions')"
:aria-label="t('grid.viewOptions')"
>
<Wrench class="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
class="w-max min-w-44 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="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<label
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent"
:class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }"
>
<input
type="checkbox"
class="h-3.5 w-3.5 shrink-0 accent-primary"
:checked="!!dataGridRef?.nullColumnsHidden"
:disabled="!dataGridRef?.canToggleAllNullColumns"
@change="dataGridRef?.toggleAllNullColumns()"
/>
<span class="min-w-0 flex items-center gap-1 font-medium">
{{ t("grid.hideNullColumns") }}
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums">
({{ dataGridRef?.allNullColumnCount }})
</span>
</span>
</label>
</PopoverContent>
</Popover>
</div>
<DataGrid
v-if="activeTab.result"

View File

@ -500,6 +500,9 @@ export default {
searchColumns: "Search columns...",
invertColumnVisibility: "Invert",
showAllColumns: "Show all",
viewOptions: "View options",
hideNullColumns: "Hide NULL",
hideNullColumnsHint: "Toggle columns whose values are all NULL in the current result.",
moreValues: "{count} more values, keep typing to narrow results",
filterByValue: "Filter by This Value",
filterExcludeValue: "Exclude This Value",

View File

@ -468,6 +468,9 @@ export default {
searchColumns: "Buscar columnas...",
invertColumnVisibility: "Invertir",
showAllColumns: "Mostrar todo",
viewOptions: "Opciones de vista",
hideNullColumns: "Ocultar NULL",
hideNullColumnsHint: "Alterna columnas cuyos valores son todos NULL en el resultado actual.",
moreValues: "{count} valores más, sigue escribiendo para acotar los resultados",
filterByValue: "Filtrar por este valor",
filterExcludeValue: "Excluir este valor",

View File

@ -495,6 +495,9 @@ export default {
searchColumns: "搜索字段...",
invertColumnVisibility: "反选",
showAllColumns: "显示全部",
viewOptions: "视图选项",
hideNullColumns: "隐藏 NULL 列",
hideNullColumnsHint: "切换隐藏当前结果中整列均为 NULL 的字段。",
moreValues: "还有 {count} 个值,输入关键词继续缩小范围",
filterByValue: "筛选此值",
filterExcludeValue: "排除此值",

View File

@ -495,6 +495,9 @@ export default {
searchColumns: "搜尋欄位……",
invertColumnVisibility: "反選",
showAllColumns: "顯示全部",
viewOptions: "檢視選項",
hideNullColumns: "隱藏 NULL 欄",
hideNullColumnsHint: "切換隱藏目前結果中整欄皆為 NULL 的欄位。",
moreValues: "還有 {count} 個值,輸入關鍵字繼續縮小範圍",
filterByValue: "篩選此值",
filterExcludeValue: "排除此值",

View File

@ -44,3 +44,48 @@ export function invertedHiddenColumnIndexes(
}
return next;
}
export function allNullColumnIndexes(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
availableIndexes: number[],
): number[] {
if (rows.length === 0) return [];
return availableIndexes.filter((index) => rows.every((row) => row[index] === null));
}
export function hiddenColumnIndexesWithAllNullColumns(options: {
availableIndexes: number[];
hiddenIndexes: ReadonlySet<number>;
allNullIndexes: ReadonlySet<number>;
}): { hiddenIndexes: Set<number>; autoHiddenIndexes: Set<number> } {
const next = new Set(options.hiddenIndexes);
const autoHidden = new Set<number>();
const available = new Set(options.availableIndexes);
for (const index of options.allNullIndexes) {
if (!available.has(index) || next.has(index)) continue;
next.add(index);
autoHidden.add(index);
}
const hasVisibleColumn = options.availableIndexes.some((index) => !next.has(index));
if (!hasVisibleColumn && options.availableIndexes.length > 0) {
const restoredIndex =
options.availableIndexes.find((index) => autoHidden.has(index)) ?? options.availableIndexes[0];
next.delete(restoredIndex);
autoHidden.delete(restoredIndex);
}
return { hiddenIndexes: next, autoHiddenIndexes: autoHidden };
}
export function removeAutoHiddenColumnIndexes(
hiddenIndexes: ReadonlySet<number>,
autoHiddenIndexes: ReadonlySet<number>,
): Set<number> {
const next = new Set(hiddenIndexes);
for (const index of autoHiddenIndexes) {
next.delete(index);
}
return next;
}

View File

@ -1,9 +1,12 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import {
allNullColumnIndexes,
filterColumnVisibilityOptions,
hiddenColumnIndexesWithAllNullColumns,
invertedHiddenColumnIndexes,
nextHiddenColumnIndexes,
removeAutoHiddenColumnIndexes,
visibleColumnIndexesForFilter,
} from "../../apps/desktop/src/lib/dataGridColumnVisibility.ts";
@ -50,3 +53,42 @@ test("keeps one column visible when inverting all visible columns", () => {
assert.deepEqual([...hidden].sort(), [1, 2]);
});
test("finds columns where every row is NULL", () => {
const indexes = allNullColumnIndexes(
[
[1, null, null, "x"],
[2, null, null, null],
],
[0, 1, 2, 3],
);
assert.deepEqual(indexes, [1, 2]);
});
test("does not treat empty results as all-null columns", () => {
assert.deepEqual(allNullColumnIndexes([], [0, 1, 2]), []);
});
test("hides all-null columns without restoring manually hidden columns", () => {
const result = hiddenColumnIndexesWithAllNullColumns({
availableIndexes: [0, 1, 2, 3],
hiddenIndexes: new Set([3]),
allNullIndexes: new Set([1, 2, 3]),
});
assert.deepEqual([...result.hiddenIndexes].sort(), [1, 2, 3]);
assert.deepEqual([...result.autoHiddenIndexes].sort(), [1, 2]);
assert.deepEqual([...removeAutoHiddenColumnIndexes(result.hiddenIndexes, result.autoHiddenIndexes)].sort(), [3]);
});
test("keeps one all-null column visible when every column is NULL", () => {
const result = hiddenColumnIndexesWithAllNullColumns({
availableIndexes: [0, 1, 2],
hiddenIndexes: new Set(),
allNullIndexes: new Set([0, 1, 2]),
});
assert.deepEqual([...result.hiddenIndexes].sort(), [1, 2]);
assert.deepEqual([...result.autoHiddenIndexes].sort(), [1, 2]);
});