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();
});
});