mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 04:22:31 +00:00
fix(grid): add tooltips for auto refresh and go-to-column toolbar controls
This commit is contained in:
@@ -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" />
|
||||
|
||||
+189
@@ -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 |
Reference in New Issue
Block a user