fix(grid): show full filter labels for long cell values

This commit is contained in:
zipg
2026-10-02 09:03:11 +08:00
committed by GitHub
parent 86467b73c1
commit b16a16aadf
7 changed files with 134 additions and 10 deletions
@@ -1388,7 +1388,7 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
getGlobalQueryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs,
getNewRows: () => getGridNewRows(),
getRowData: (row, sourceIndex) => getGridRowData(row, sourceIndex),
formatValue: formatCellCached,
formatValue: (value, columnIndex) => formatCell(value, columnIndex, undefined, false),
waitForTableMeta,
applyWhereFilter,
resetGridVerticalScroll: () => resetLocalFilterGridScroll(),
@@ -2,6 +2,7 @@
import { ArrowDown, ArrowUp, ArrowUpDown, Check, Database, Filter, Loader2, Search } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { DataGridLocalFilterOption, DataGridLocalFilterSort } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
@@ -114,15 +115,20 @@ const { t } = useI18n();
</span>
</div>
<div class="max-h-72 overflow-auto py-0.5">
<button v-for="option in props.options" :key="option.key" type="button" class="grid w-full grid-cols-[1.75rem_minmax(0,1fr)_3.5rem] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="emit('toggleValue', option.key)">
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="props.draftValues?.has(option.key) ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'">
<Check v-if="props.draftValues?.has(option.key)" class="h-3 w-3 stroke-[3]" />
</span>
<span class="truncate font-mono" :class="{ 'italic text-muted-foreground': option.value === null }">
{{ option.label }}
</span>
<span class="text-right tabular-nums text-muted-foreground text-xs">{{ option.count ?? "" }}</span>
</button>
<LightTooltip v-for="option in props.options" :key="option.key" :text="option.label" side="left" content-class="w-max max-w-[min(32rem,calc(100vw-2rem))]">
<button type="button" class="grid w-full grid-cols-[1.75rem_minmax(0,1fr)_3.5rem] items-center px-2 py-1 text-left text-xs hover:bg-accent" @click="emit('toggleValue', option.key)">
<span class="flex h-4 w-4 items-center justify-center rounded border" :class="props.draftValues?.has(option.key) ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'">
<Check v-if="props.draftValues?.has(option.key)" class="h-3 w-3 stroke-[3]" />
</span>
<span class="truncate font-mono" :class="{ 'italic text-muted-foreground': option.value === null }">
{{ option.label }}
</span>
<span class="text-right tabular-nums text-muted-foreground text-xs">{{ option.count ?? "" }}</span>
</button>
<template #content>
<div class="max-h-72 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono">{{ option.label }}</div>
</template>
</LightTooltip>
<div v-if="props.draftMode === 'local' && props.allOptionsCount > props.options.length" class="px-2 py-0.5 text-center text-[10px] text-muted-foreground">
{{ t("grid.moreValues", { count: props.allOptionsCount - props.options.length }) }}
</div>
@@ -11,6 +11,7 @@ vi.mock("@lucide/vue", async () => {
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/LightTooltip.vue", async () => ({ default: (await import("./vueHostHarness")).createPassthroughStub("LightTooltip") }));
vi.mock("@/components/ui/popover", async () => {
const { createPassthroughStub } = await import("./vueHostHarness");
return { Popover: createPassthroughStub("Popover"), PopoverAnchor: createPassthroughStub("PopoverAnchor"), PopoverContent: createPassthroughStub("PopoverContent"), PopoverTrigger: createPassthroughStub("PopoverTrigger") };
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, markRaw, nextTick, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { QueryResult } from "@/types/database";
vi.mock("@/composables/useDataGridColumnResize", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useDataGridColumnResize")>();
const { ref } = await import("vue");
return {
...actual,
useDataGridColumnResize: () => ({
initColumnWidths: vi.fn(),
onResizeStart: vi.fn(),
autoFitColumn: vi.fn(),
renderedColumnWidths: ref([120]),
totalWidth: ref(120),
columnVars: ref({ "--total-w": "120px" }),
getIsResizing: () => false,
}),
};
});
import DataGrid from "../DataGrid.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const prefix = "long-value-prefix-".repeat(30);
const alpha = `${prefix}ALPHA_END`;
const beta = `${prefix}BETA_END`;
const mounted: Array<{ app: App; host: HTMLElement }> = [];
async function settle() {
for (let i = 0; i < 6; i++) {
await nextTick();
await Promise.resolve();
}
}
async function openFilter() {
const pinia = createPinia();
setActivePinia(pinia);
useSettingsStore().updateEditorSettings({ dataGridRenderMode: "canvas", compactColumnHeaderActions: false });
const result = markRaw<QueryResult>({ columns: ["label"], column_types: ["varchar"], rows: [[beta], [alpha]], affected_rows: 2, execution_time_ms: 1 });
const onLocalColumnFiltersChange = vi.fn();
const Root = defineComponent({
setup: () => () => h(TooltipProvider, { delayDuration: 0 }, { default: () => h(DataGrid, { result, databaseType: "mysql", context: "table-data", onLocalColumnFiltersChange }) }),
});
const host = document.createElement("div");
document.body.append(host);
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.mount(host);
mounted.push({ app, host });
await settle();
const trigger = host.querySelector<HTMLButtonElement>(`button[title="${i18n.global.t("grid.localFilter")}"]`)!;
expect(trigger).toBeTruthy();
trigger.click();
await settle();
return { onLocalColumnFiltersChange };
}
function valueButtons() {
return Array.from(document.querySelectorAll<HTMLButtonElement>("button")).filter((button) => button.textContent?.includes("long-value-prefix-"));
}
afterEach(() => {
for (const { app, host } of mounted.splice(0)) {
app.unmount();
host.remove();
}
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("DataGrid local filter full values", () => {
it("keeps distinct suffixes in sorted option labels beyond the grid display limit", async () => {
await openFilter();
expect(valueButtons().map((button) => button.textContent)).toEqual([`${alpha}1`, `${beta}1`]);
});
it("finds an actual candidate when searching for its suffix beyond the display limit", async () => {
await openFilter();
const search = document.querySelector<HTMLInputElement>(`input[placeholder="${i18n.global.t("grid.searchValues")}"]`)!;
search.value = "BETA_END";
search.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
expect(valueButtons()).toHaveLength(1);
expect(valueButtons()[0].textContent).toBe(`${beta}1`);
});
it("filters with the original full value after changing the selected candidate", async () => {
const { onLocalColumnFiltersChange } = await openFilter();
const alphaButton = valueButtons().find((button) => button.textContent?.includes("ALPHA_END")) ?? valueButtons()[1];
alphaButton.click();
await settle();
const apply = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === i18n.global.t("grid.applyFilter"))!;
apply.click();
await settle();
expect(onLocalColumnFiltersChange).toHaveBeenLastCalledWith({ "0": [`str:${beta}`] });
});
it.each(["hover", "focus"] as const)("exposes the full formatted candidate on %s", async (interaction) => {
await openFilter();
const button = valueButtons()[0];
vi.useFakeTimers();
vi.spyOn(button, "matches").mockImplementation((selector) => selector === (interaction === "hover" ? ":hover" : ":focus-visible"));
if (interaction === "hover") button.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
else button.focus();
await vi.advanceTimersByTimeAsync(350);
await settle();
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(alpha);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB