feat(grid): sort local filter values by count

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-23 15:40:16 +08:00
1 parent d13bc37bea
commit 33eb12d9f8
7 files changed
+310 -16

No files matched your search

@@ -1329,6 +1329,8 @@ const {
localFilteredRows,
localFilterAllOptions,
localFilterOptions,
localFilterSort,
toggleLocalFilterSort,
localFilterTypedValue,
canApplyTypedLocalFilterValue,
openLocalFilter,
@@ -13001,6 +13003,8 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
:draft-mode="localFilterDraft?.mode"
:draft-values="localFilterDraft?.values"
:options="localFilterOptions"
:sort="localFilterSort"
@sort="toggleLocalFilterSort"
:all-options-count="localFilterAllOptions.length"
:can-apply-typed-value="canApplyTypedLocalFilterValue"
:typed-value="localFilterTypedValue"
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { Check, Database, Filter, Loader2, Search } from "@lucide/vue";
import { ArrowDown, ArrowUp, ArrowUpDown, Check, Database, Filter, Loader2, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import type { DataGridLocalFilterOption, DataGridLocalFilterSort } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
type LocalFilterMode = "local" | "server";
@@ -20,6 +20,7 @@ interface DataGridColumnFilterPopoverProps {
draftMode?: LocalFilterMode;
draftValues?: Set<string>;
options: DataGridLocalFilterOption[];
sort?: DataGridLocalFilterSort;
allOptionsCount: number;
canApplyTypedValue: boolean;
typedValue: string;
@@ -35,6 +36,7 @@ const emit = defineEmits<{
"update:open": [value: boolean];
"update:search": [value: string];
resizeStart: [event: MouseEvent, direction: "left" | "right"];
sort: [field: DataGridLocalFilterSort["field"]];
toggleAll: [];
toggleValue: [key: string];
applyTypedValue: [];
@@ -84,8 +86,24 @@ const { t } = useI18n();
>
<Check v-if="props.options.length > 0 && props.draftValues && props.options.every((option) => props.draftValues?.has(option.key))" class="h-3 w-3 stroke-[3]" />
</button>
<span>{{ t("grid.value") }}</span>
<span class="text-right">{{ t("grid.count") }}</span>
<template v-if="props.draftMode === 'local' && props.sort">
<button
v-for="field in ['value', 'count'] as const"
:key="field"
type="button"
class="flex min-w-0 items-center gap-0.5 rounded hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:class="field === 'count' ? 'justify-end' : 'justify-start'"
:aria-label="`${t(`grid.${field}`)}: ${t(props.sort.field === field ? (props.sort.direction === 'asc' ? 'grid.sortAscending' : 'grid.sortDescending') : field === 'count' ? 'grid.sortDescending' : 'grid.sortAscending')}`"
@click.stop="emit('sort', field)"
>
<span>{{ t(`grid.${field}`) }}</span>
<component :is="props.sort.field === field ? (props.sort.direction === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown" class="h-3 w-3 shrink-0" aria-hidden="true" />
</button>
</template>
<template v-else>
<span>{{ t("grid.value") }}</span>
<span class="text-right">{{ t("grid.count") }}</span>
</template>
</div>
<div v-if="props.draftMode === 'server' && (props.serverLoading || props.serverError || props.serverLimited)" class="flex items-center gap-1.5 border-b px-2 py-1 text-[11px] text-muted-foreground">
<Loader2 v-if="props.serverLoading" class="h-3 w-3 animate-spin" />
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { dispatch, findAll, findOne, mountComponent } from "./vueHostHarness";
import type { DataGridLocalFilterOption, DataGridLocalFilterSort } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", async () => {
const { createPassthroughStub } = await import("./vueHostHarness");
const icon = createPassthroughStub("Icon", "i");
return { ArrowDown: icon, ArrowUp: icon, ArrowUpDown: icon, Check: icon, Database: icon, Filter: icon, Loader2: icon, Search: icon };
});
vi.mock("@/components/ui/button", async () => ({ Button: (await import("./vueHostHarness")).createPassthroughStub("Button", "button") }));
vi.mock("@/components/ui/popover", async () => {
const { createPassthroughStub } = await import("./vueHostHarness");
return { Popover: createPassthroughStub("Popover"), PopoverAnchor: createPassthroughStub("PopoverAnchor"), PopoverContent: createPassthroughStub("PopoverContent"), PopoverTrigger: createPassthroughStub("PopoverTrigger") };
});
import DataGridColumnFilterPopover from "@/components/grid/DataGridColumnFilterPopover.vue";
const options: DataGridLocalFilterOption[] = [
{ key: "str:alpha", label: "alpha", count: 1, value: "alpha" },
{ key: "str:beta", label: "beta", count: 3, value: "beta" },
];
function mountPopover(sort: DataGridLocalFilterSort, onSort: ReturnType<typeof vi.fn>) {
return mountComponent(DataGridColumnFilterPopover, {
open: true,
compactHeaderActions: false,
canUseServerFilter: false,
active: false,
serverModeActive: false,
panelTitle: "status",
search: "",
popoverWidth: 280,
popoverOffsetX: 0,
draftMode: "local",
draftValues: new Set(options.map((option) => option.key)),
options,
sort,
allOptionsCount: options.length,
canApplyTypedValue: false,
typedValue: "",
serverLoading: false,
serverError: "",
serverLimited: false,
serverValueLimit: 1000,
onSort,
});
}
describe("DataGridColumnFilterPopover sort toggles", () => {
it("emits sort with the clicked field and announces count-desc / value-asc as the first-click directions", async () => {
const onSort = vi.fn();
const mounted = mountPopover({ field: "value", direction: "asc" }, onSort);
// From the default value-asc state, the count header advertises descending
// for its first click while the active value header stays ascending.
const countButton = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.count: grid.sortDescending");
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.value: grid.sortAscending")).toBeTruthy();
dispatch(countButton, "click");
expect(onSort).toHaveBeenCalledExactlyOnceWith("count");
await mounted.setProps({ sort: { field: "count", direction: "desc" } });
// Switching back from count-desc, the value header advertises ascending.
const valueButton = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.value: grid.sortAscending");
dispatch(valueButton, "click");
expect(onSort).toHaveBeenLastCalledWith("value");
expect(onSort).toHaveBeenCalledTimes(2);
mounted.unmount();
});
it("renders plain labels without sort controls outside the local draft mode", async () => {
const onSort = vi.fn();
const mounted = mountPopover({ field: "value", direction: "asc" }, onSort);
await mounted.setProps({ draftMode: "server" });
expect(findAll(mounted.root, (node) => node.type === "button" && typeof node.props["aria-label"] === "string" && node.props["aria-label"].startsWith("grid.value: "))).toHaveLength(0);
expect(findAll(mounted.root, (node) => node.type === "button" && typeof node.props["aria-label"] === "string" && node.props["aria-label"].startsWith("grid.count: "))).toHaveLength(0);
expect(onSort).not.toHaveBeenCalled();
mounted.unmount();
});
});
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, ref, computed } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useDataGridColumnFilters, type DataGridColumnFilterState, type DataGridLocalFilterDraft } from "@/composables/useDataGridColumnFilters";
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import type { QueryResult } from "@/types/database";
vi.mock("@/lib/backend/api", () => ({ executeQuery: vi.fn() }));
const disposers: Array<() => void> = [];
function mountColumnFilters(rows: CellValue[][]) {
const state: DataGridColumnFilterState = {
localColumnFilters: ref({}),
localFilterOpenColumn: ref<number | null>(null),
localFilterSearch: ref(""),
localFilterDraft: ref<DataGridLocalFilterDraft | null>(null),
serverFilterLoading: ref(false),
serverFilterError: ref(""),
serverFilterOptions: ref<DataGridLocalFilterOption[]>([]),
serverFilterLimited: ref(false),
serverFilterValueByKey: ref(new Map()),
serverColumnFilters: ref({}),
};
const result: QueryResult = { columns: ["status"], rows };
let filters: ReturnType<typeof useDataGridColumnFilters> | undefined;
const app = createApp(
defineComponent({
setup() {
filters = useDataGridColumnFilters({
state,
getResult: () => result,
getTableMeta: () => undefined,
getConnectionId: () => undefined,
getSchema: () => undefined,
getExecutionDatabase: () => "",
resolvedDatabaseType: computed(() => undefined),
canUseWhereSearch: computed(() => false),
canUseServerColumnFilter: computed(() => false),
structuredFilterCount: computed(() => 0),
hasStructuredFilters: computed(() => false),
whereFilterInput: ref(""),
getConnectionConfig: () => undefined,
getIdentifierQuote: () => undefined,
getNewRows: () => [],
getRowData: (row) => row,
formatValue: (value) => String(value),
waitForTableMeta: () => Promise.resolve(null),
applyWhereFilter: () => Promise.resolve(),
resetGridVerticalScroll: () => {},
emitLocalFiltersChange: () => {},
});
return () => h("div");
},
}),
);
app.mount(document.createElement("div"));
disposers.push(() => app.unmount());
if (!filters) throw new Error("useDataGridColumnFilters was not mounted");
return filters;
}
afterEach(() => {
for (const dispose of disposers.splice(0)) dispose();
});
describe("useDataGridColumnFilters local filter sort", () => {
it("sorts values by count descending on the first count click and back to value ascending on the first value click", () => {
const filters = mountColumnFilters([["beta"], ["beta"], ["beta"], ["alpha"], ["alpha"], ["zulu"]]);
filters.openLocalFilter(0);
expect(filters.localFilterSort.value).toEqual({ field: "value", direction: "asc" });
expect(filters.localFilterOptions.value.map((option) => option.label)).toEqual(["alpha", "beta", "zulu"]);
filters.toggleLocalFilterSort("count");
expect(filters.localFilterSort.value).toEqual({ field: "count", direction: "desc" });
expect(filters.localFilterOptions.value.map((option) => [option.label, option.count])).toEqual([
["beta", 3],
["alpha", 2],
["zulu", 1],
]);
filters.toggleLocalFilterSort("value");
expect(filters.localFilterSort.value).toEqual({ field: "value", direction: "asc" });
expect(filters.localFilterOptions.value.map((option) => option.label)).toEqual(["alpha", "beta", "zulu"]);
});
it("flips the direction when the same sort header is clicked again", () => {
const filters = mountColumnFilters([["beta"], ["alpha"], ["alpha"]]);
filters.openLocalFilter(0);
filters.toggleLocalFilterSort("count");
expect(filters.localFilterSort.value).toEqual({ field: "count", direction: "desc" });
expect(filters.localFilterOptions.value.map((option) => [option.label, option.count])).toEqual([
["alpha", 2],
["beta", 1],
]);
filters.toggleLocalFilterSort("count");
expect(filters.localFilterSort.value).toEqual({ field: "count", direction: "asc" });
expect(filters.localFilterOptions.value.map((option) => [option.label, option.count])).toEqual([
["beta", 1],
["alpha", 2],
]);
});
it("ignores sort toggles while no local filter draft is open", () => {
const filters = mountColumnFilters([["beta"], ["alpha"]]);
filters.toggleLocalFilterSort("count");
expect(filters.localFilterSort.value).toEqual({ field: "value", direction: "asc" });
});
});
@@ -1,8 +1,8 @@
import { computed, onUnmounted, watch, type ComputedRef, type Ref } from "vue";
import { computed, onUnmounted, ref, watch, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/backend/api";
import { buildColumnValueFilterCondition, buildColumnValuesFilterCondition, parseFilterValue, removeColumnValueFilterCondition, replaceColumnValueFilterCondition, appendColumnValueFilterCondition } from "@/lib/dataGrid/dataGridColumnFilter";
import { buildDataGridColumnDistinctValuesSql } from "@/lib/dataGrid/dataGridSql";
import { buildDataGridLocalFilterOptions, dataGridLocalFilterKey, dataGridLocalFilterLabel, rowMatchesDataGridLocalColumnFilters, type DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import { buildDataGridLocalFilterOptions, sortDataGridLocalFilterOptions, type DataGridLocalFilterSort, dataGridLocalFilterKey, dataGridLocalFilterLabel, rowMatchesDataGridLocalColumnFilters, type DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import type { DataGridCachedServerColumnFilter } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
@@ -72,6 +72,17 @@ const SERVER_COLUMN_FILTER_DEBOUNCE_MS = 300;
export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOptions) {
const { state } = options;
const localFilterSort = ref<DataGridLocalFilterSort>({ field: "value", direction: "asc" });
function toggleLocalFilterSort(field: DataGridLocalFilterSort["field"]) {
if (state.localFilterDraft.value?.mode !== "local") return;
const current = localFilterSort.value;
localFilterSort.value = {
field,
direction: current.field === field ? (current.direction === "asc" ? "desc" : "asc") : field === "count" ? "desc" : "asc",
};
}
let serverFilterRequestId = 0;
let serverFilterSearchTimer: ReturnType<typeof window.setTimeout> | undefined;
@@ -138,7 +149,13 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
const localFilterOptions = computed(() => {
if (state.localFilterDraft.value?.mode === "server") return state.serverFilterOptions.value;
const query = state.localFilterSearch.value.trim().toLowerCase();
return localFilterAllOptions.value.filter((option) => !query || option.label.toLowerCase().includes(query)).slice(0, 500);
const matchingOptions = localFilterAllOptions.value.filter((option) => !query || option.label.toLowerCase().includes(query));
// Sort before limiting so frequent values outside the default first 500 remain discoverable.
// buildDataGridLocalFilterOptions already returns value-ascending options, so the default
// sort state skips the re-sort instead of re-running it on every search keystroke.
const sort = localFilterSort.value;
const sorted = sort.field === "value" && sort.direction === "asc" ? matchingOptions : sortDataGridLocalFilterOptions(matchingOptions, sort);
return sorted.slice(0, 500);
});
const localFilterTypedValue = computed(() => state.localFilterSearch.value.trim());
const localFilterDraftIsAllSelected = computed(() => {
@@ -172,6 +189,7 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
function openLocalFilter(columnIndex: number, requestedMode: DataGridLocalFilterMode = "local") {
options.onOpen?.();
localFilterSort.value = { field: "value", direction: "asc" };
state.localFilterSearch.value = "";
const mode = requestedMode === "server" && options.canUseServerColumnFilter.value ? "server" : "local";
const allKeys = mode === "server" ? [] : buildLocalFilterOptions(columnIndex).map((option) => option.key);
@@ -426,6 +444,8 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
rowMatchesLocalColumnFilters,
localFilterAllOptions,
localFilterOptions,
localFilterSort,
toggleLocalFilterSort,
localFilterTypedValue,
canApplyTypedLocalFilterValue,
openLocalFilter,
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildDataGridLocalFilterOptions, dataGridLocalFilterKey, restoreDataGridLocalColumnFilters, rowMatchesDataGridLocalColumnFilters, serializeDataGridLocalColumnFilters } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
import { buildDataGridLocalFilterOptions, dataGridLocalFilterKey, restoreDataGridLocalColumnFilters, rowMatchesDataGridLocalColumnFilters, serializeDataGridLocalColumnFilters, sortDataGridLocalFilterOptions, type DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
function filterOption(label: string, count: number | null): DataGridLocalFilterOption {
return { key: `str:${label}`, label, count, value: label };
}
describe("data grid local column filter state", () => {
it("round-trips selected values so a remounted grid restores its filters", () => {
@@ -67,4 +71,36 @@ describe("data grid local column filter state", () => {
{ key: "str:beta", label: "beta", count: 2, value: "beta" },
]);
});
it("sorts filter options by count in both directions and treats a missing count as zero", () => {
const options = [filterOption("beta", 2), filterOption("alpha", 5), filterOption("delta", null), filterOption("gamma", 1)];
expect(sortDataGridLocalFilterOptions(options, { field: "count", direction: "asc" }).map((option) => option.label)).toEqual(["delta", "gamma", "beta", "alpha"]);
expect(sortDataGridLocalFilterOptions(options, { field: "count", direction: "desc" }).map((option) => option.label)).toEqual(["alpha", "beta", "gamma", "delta"]);
});
it("breaks equal-count ties by value ascending regardless of the count direction", () => {
const options = [filterOption("delta", 3), filterOption("beta", 3), filterOption("alpha", 3)];
expect(sortDataGridLocalFilterOptions(options, { field: "count", direction: "asc" }).map((option) => option.label)).toEqual(["alpha", "beta", "delta"]);
expect(sortDataGridLocalFilterOptions(options, { field: "count", direction: "desc" }).map((option) => option.label)).toEqual(["alpha", "beta", "delta"]);
});
it("keeps null first for value ascending and last for value descending", () => {
const options: DataGridLocalFilterOption[] = [filterOption("alpha", 1), { key: "__dbx_null__", label: "NULL", count: null, value: null }, filterOption("beta", 1)];
expect(sortDataGridLocalFilterOptions(options, { field: "value", direction: "asc" }).map((option) => option.value)).toEqual([null, "alpha", "beta"]);
expect(sortDataGridLocalFilterOptions(options, { field: "value", direction: "desc" }).map((option) => option.value)).toEqual(["beta", "alpha", null]);
});
it("returns a new sorted array without mutating the input options", () => {
const options = [filterOption("beta", 2), filterOption("alpha", 5)];
const snapshot = options.map((option) => ({ ...option }));
const sorted = sortDataGridLocalFilterOptions(options, { field: "count", direction: "desc" });
expect(sorted).not.toBe(options);
expect(sorted.map((option) => option.label)).toEqual(["alpha", "beta"]);
expect(options).toEqual(snapshot);
});
});
@@ -9,6 +9,27 @@ export type DataGridLocalFilterOption = {
value: CellValue;
};
export type DataGridLocalFilterSort = {
field: "value" | "count";
direction: "asc" | "desc";
};
function compareLocalFilterValues(a: DataGridLocalFilterOption, b: DataGridLocalFilterOption): number {
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" });
}
export function sortDataGridLocalFilterOptions(options: readonly DataGridLocalFilterOption[], sort: DataGridLocalFilterSort): DataGridLocalFilterOption[] {
const direction = sort.direction === "asc" ? 1 : -1;
return [...options].sort((a, b) => {
if (sort.field === "count") {
return ((a.count ?? 0) - (b.count ?? 0)) * direction || compareLocalFilterValues(a, b);
}
return compareLocalFilterValues(a, b) * direction;
});
}
export function dataGridLocalFilterKey(value: CellValue): string {
if (value === null) return "__dbx_null__";
if (typeof value === "boolean") return `bool:${value}`;
@@ -62,14 +83,7 @@ export function buildDataGridLocalFilterOptions<TRow>({
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",
});
});
return sortDataGridLocalFilterOptions([...byKey.values()], { field: "value", direction: "asc" });
}
export function restoreDataGridLocalColumnFilters(serialized: SerializedDataGridLocalColumnFilters | undefined, columnCount: number, currentColumns?: readonly string[], serializedColumns?: readonly string[]): Record<number, Set<string>> {