fix(grid): add tooltips for auto refresh and go-to-column toolbar controls

This commit is contained in:
zipg
2026-09-29 13:39:58 +08:00
committed by GitHub
parent 092a562ebf
commit 6ffc462790
5 changed files with 244 additions and 51 deletions
+36 -36
View File
@@ -12524,9 +12524,9 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
</template>
<template #navigation="{ compact }">
<Tooltip v-if="props.result.columns.length">
<TooltipTrigger as-child>
<Popover v-model:open="goToColumnOpen">
<Popover v-if="props.result.columns.length" v-model:open="goToColumnOpen">
<Tooltip>
<TooltipTrigger as-child>
<PopoverTrigger as-child>
<Button data-toolbar-action="navigation" variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compact ? 'data-grid-topbar-action-button--compact' : '', goToColumnOpen ? 'text-primary bg-primary/10' : '']">
<Columns3 class="data-grid-topbar-action-icon w-3 h-3" />
@@ -12539,39 +12539,39 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-56 p-2" @keydown="onGoToColumnKeydown">
<div class="relative mb-1">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input ref="goToColumnSearchInput" v-model="goToColumnSearch" :placeholder="t('grid.searchColumn')" class="h-8 w-full rounded-md border bg-transparent pl-7 pr-6 text-xs outline-none focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25" />
<button v-if="goToColumnSearch" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="goToColumnSearch = ''">
<X class="h-3.5 w-3.5" />
</button>
</div>
<div ref="goToColumnListRef" class="max-h-56 overflow-auto rounded border">
<button
v-for="(column, index) in filteredGoToColumns"
:key="column.index"
type="button"
:class="[
'grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-0.5 px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
index === goToColumnSelectedIndex ? 'bg-accent text-accent-foreground' : '',
]"
@pointerenter="goToColumnSelectedIndex = index"
@click="scrollToColumn(column.index)"
>
<span class="min-w-0 truncate">{{ column.name }}</span>
<span class="shrink-0 font-mono text-[10px] text-muted-foreground">#{{ column.index + 1 }}</span>
<span v-if="column.comment" class="col-span-2 min-w-0 truncate text-[11px] leading-4 text-muted-foreground" :title="column.comment">{{ column.comment }}</span>
</button>
<div v-if="!filteredGoToColumns.length" class="px-2 py-3 text-center text-xs text-muted-foreground">
{{ t("grid.noColumnsFound") }}
</div>
</div>
</PopoverContent>
</Popover>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.goToColumn") }}</TooltipContent>
</Tooltip>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.goToColumn") }}</TooltipContent>
</Tooltip>
<PopoverContent align="end" class="w-56 p-2" @keydown="onGoToColumnKeydown">
<div class="relative mb-1">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input ref="goToColumnSearchInput" v-model="goToColumnSearch" :placeholder="t('grid.searchColumn')" class="h-8 w-full rounded-md border bg-transparent pl-7 pr-6 text-xs outline-none focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25" />
<button v-if="goToColumnSearch" type="button" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="goToColumnSearch = ''">
<X class="h-3.5 w-3.5" />
</button>
</div>
<div ref="goToColumnListRef" class="max-h-56 overflow-auto rounded border">
<button
v-for="(column, index) in filteredGoToColumns"
:key="column.index"
type="button"
:class="[
'grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-0.5 px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
index === goToColumnSelectedIndex ? 'bg-accent text-accent-foreground' : '',
]"
@pointerenter="goToColumnSelectedIndex = index"
@click="scrollToColumn(column.index)"
>
<span class="min-w-0 truncate">{{ column.name }}</span>
<span class="shrink-0 font-mono text-[10px] text-muted-foreground">#{{ column.index + 1 }}</span>
<span v-if="column.comment" class="col-span-2 min-w-0 truncate text-[11px] leading-4 text-muted-foreground" :title="column.comment">{{ column.comment }}</span>
</button>
<div v-if="!filteredGoToColumns.length" class="px-2 py-3 text-center text-xs text-muted-foreground">
{{ t("grid.noColumnsFound") }}
</div>
</div>
</PopoverContent>
</Popover>
</template>
</DataGridToolbar>
</div>
@@ -94,21 +94,25 @@ function actionLabelClass(action: DataGridToolbarActionKey) {
</Tooltip>
<DropdownMenu v-if="isDataGridToolbarCapabilityVisible(autoRefresh)">
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
data-toolbar-action="autoRefresh"
:class="[...actionButtonClass('autoRefresh'), autoRefresh?.enabled ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground hover:text-foreground']"
:disabled="isDataGridToolbarCapabilityDisabled(autoRefresh)"
:title="autoRefresh?.label"
:aria-label="autoRefresh?.label"
:aria-pressed="autoRefresh?.enabled"
>
<DataGridAutoRefreshClock :enabled="autoRefresh?.enabled === true" :interval-seconds="autoRefresh?.intervalSeconds" :sweep-key="autoRefresh?.sweepKey" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass('autoRefresh')">{{ autoRefresh?.enabled ? `${autoRefresh.intervalSeconds}s` : autoRefresh?.shortLabel }}</span>
</Button>
</DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="sm"
data-toolbar-action="autoRefresh"
:class="[...actionButtonClass('autoRefresh'), autoRefresh?.enabled ? 'bg-primary/10 text-primary hover:bg-primary/15' : 'text-muted-foreground hover:text-foreground']"
:disabled="isDataGridToolbarCapabilityDisabled(autoRefresh)"
:aria-label="autoRefresh?.label"
:aria-pressed="autoRefresh?.enabled"
>
<DataGridAutoRefreshClock :enabled="autoRefresh?.enabled === true" :interval-seconds="autoRefresh?.intervalSeconds" :sweep-key="autoRefresh?.sweepKey" />
<span class="data-grid-topbar-action-label" :class="actionLabelClass('autoRefresh')">{{ autoRefresh?.enabled ? `${autoRefresh.intervalSeconds}s` : autoRefresh?.shortLabel }}</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">{{ autoRefresh?.label ?? autoRefresh?.shortLabel }}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" class="w-40">
<DropdownMenuItem class="gap-2" :disabled="autoRefresh?.disabled" @select="void toggleDataGridToolbarAutoRefresh(autoRefresh)">
<Check v-if="autoRefresh?.enabled" class="h-3.5 w-3.5" />
@@ -0,0 +1,189 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, markRaw, nextTick, type App, type PropType } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { QueryResult } from "@/types/database";
import { TooltipProvider } from "@/components/ui/tooltip";
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 DataGridToolbar from "../DataGridToolbar.vue";
import { useSettingsStore } from "@/stores/settingsStore";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
const RecycleScroller = defineComponent({
props: {
items: {
type: Array as PropType<unknown[]>,
default: () => [],
},
},
setup(props, { attrs, slots }) {
return () =>
h(
"div",
attrs,
props.items.map((item) => slots.default?.({ item })),
);
},
});
function mountWithProvider(render: () => ReturnType<typeof h>) {
const pinia = createPinia();
setActivePinia(pinia);
const host = document.createElement("div");
document.body.append(host);
const Root = defineComponent({
setup() {
return () => h(TooltipProvider, { delayDuration: 0 }, { default: render });
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.component("RecycleScroller", RecycleScroller);
app.mount(host);
const mounted = { app, host };
mountedApps.push(mounted);
return mounted;
}
function mountToolbar() {
return mountWithProvider(() =>
h(DataGridToolbar, {
compact: true,
refresh: { label: "Refresh", tooltip: "Refresh (Mod+R)", onTrigger: vi.fn() },
autoRefresh: {
label: "Auto-refresh results",
shortLabel: "Auto",
startLabel: "Start auto-refresh",
stopLabel: "Stop auto-refresh",
enabled: false,
intervalSeconds: 30,
sweepKey: 0,
intervalOptions: [10, 30, 60],
intervalLabel: (seconds: number) => `Refresh every ${seconds}s`,
onToggle: vi.fn(),
onSelectInterval: vi.fn(),
},
}),
);
}
function mountGrid() {
const pinia = createPinia();
setActivePinia(pinia);
const settingsStore = useSettingsStore();
settingsStore.updateEditorSettings({
dataGridRenderMode: "canvas",
shortcuts: { ...settingsStore.editorSettings.shortcuts, goToColumn: "Mod+G" },
});
const result = markRaw<QueryResult>({
columns: ["id"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 0,
});
const host = document.createElement("div");
document.body.append(host);
const Root = defineComponent({
setup() {
return () => h(TooltipProvider, { delayDuration: 0 }, { default: () => h(DataGrid, { result, databaseType: "mysql", context: "table-data" }) });
},
});
const app = createApp(Root);
app.use(pinia);
app.use(i18n);
app.component("RecycleScroller", RecycleScroller);
app.mount(host);
const mounted = { app, host };
mountedApps.push(mounted);
return mounted;
}
async function settle() {
await nextTick();
await Promise.resolve();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 20));
await nextTick();
}
function toolbarButton(host: HTMLElement, action: string): HTMLButtonElement {
const button = host.querySelector<HTMLButtonElement>(`[data-toolbar-action="${action}"]`);
if (!button) throw new Error(`Toolbar button ${action} not found`);
return button;
}
function visibleTooltipTexts(): string[] {
return [...document.querySelectorAll<HTMLElement>('[data-slot="tooltip-content"]')].map((element) => element.textContent?.trim() ?? "");
}
function showsTooltip(text: string): boolean {
return visibleTooltipTexts().some((candidate) => candidate.includes(text));
}
async function hover(element: HTMLElement) {
element.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, cancelable: true, pointerType: "mouse" }));
await settle();
}
afterEach(() => {
vi.restoreAllMocks();
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
document.body.innerHTML = "";
});
describe("data grid icon-only toolbar tooltips", () => {
it("keeps the existing refresh tooltip (control case)", async () => {
const { host } = mountToolbar();
await settle();
const button = toolbarButton(host, "refresh");
await hover(button);
expect(showsTooltip("Refresh (Mod+R)")).toBe(true);
});
it("shows a tooltip for the auto refresh control", async () => {
const { host } = mountToolbar();
await settle();
const button = toolbarButton(host, "autoRefresh");
await hover(button);
expect(showsTooltip("Auto-refresh results")).toBe(true);
});
it("shows a tooltip for the go to column control", async () => {
const { host } = mountGrid();
await settle();
const button = toolbarButton(host, "navigation");
await hover(button);
expect(showsTooltip("Go to column")).toBe(true);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB