mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(tabs): optimize result run tabs layout and middle click close
This commit is contained in:
@@ -2103,6 +2103,7 @@ defineExpose({
|
||||
class="group/result-run inline-flex h-7 shrink-0 select-none items-center overflow-hidden rounded-md border transition-colors"
|
||||
:class="run.active ? 'border-border bg-background text-foreground shadow-sm' : 'border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/70 hover:text-foreground'"
|
||||
@contextmenu="onContextMenu"
|
||||
@mousedown.middle.stop.prevent="removeResultRun(run.id)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2114,6 +2115,7 @@ defineExpose({
|
||||
class="flex h-full select-none items-center gap-1 whitespace-nowrap pl-2.5 pr-1 text-xs font-medium outline-none focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
|
||||
@click="selectResultRunFromTab(run.id)"
|
||||
@keydown="onResultRunTabKeydown($event, runIndex)"
|
||||
@mousedown.middle.stop.prevent="removeResultRun(run.id)"
|
||||
>
|
||||
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||||
{{ run.title || run.sourceLabel || resultRunFallbackLabel(run.sequence) }}
|
||||
@@ -2132,7 +2134,7 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="showResultRunSelector" class="min-w-0" :class="{ 'flex-1': visibleResultItems.length === 0 }">
|
||||
<div v-else-if="showResultRunSelector" class="min-w-0" :class="{ 'flex-1': visibleResultItems.length <= 1 }">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
|
||||
@@ -2142,7 +2144,7 @@ defineExpose({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="w-48">
|
||||
<CustomContextMenu v-for="run in resultRuns" :key="run.id" :items="() => resultRunContextMenuItems(run)" v-slot="{ onContextMenu }">
|
||||
<DropdownMenuItem class="flex items-center gap-2 pr-1" @select="selectResultRunFromTab(run.id)" @contextmenu="onContextMenu">
|
||||
<DropdownMenuItem class="flex items-center gap-2 pr-1" @select="selectResultRunFromTab(run.id)" @contextmenu="onContextMenu" @mousedown.middle.stop.prevent="removeResultRun(run.id)">
|
||||
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span v-else class="h-3.5 w-3.5 shrink-0" />
|
||||
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||||
@@ -2162,9 +2164,9 @@ defineExpose({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div v-else-if="resultRuns.length > 0" class="min-w-0 flex-1" />
|
||||
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 0" class="mx-1 h-4 w-px shrink-0 bg-border" />
|
||||
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 1" class="mx-1 h-4 w-px shrink-0 bg-border" />
|
||||
<ResultSetNavigator
|
||||
v-if="visibleResultItems.length > 0"
|
||||
v-if="resultRuns.length > 0 ? visibleResultItems.length > 1 : visibleResultItems.length > 0"
|
||||
:key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`"
|
||||
:items="visibleResultItems"
|
||||
:busy="resultBatchBusy"
|
||||
@@ -2172,6 +2174,7 @@ defineExpose({
|
||||
:active-index="activeTab.activeResultIndex ?? 0"
|
||||
:active="activeOutputView === 'result'"
|
||||
:display-mode="resultRunDisplayMode"
|
||||
:has-sibling-run-tabs="resultRuns.length > 0"
|
||||
@select="selectResultItem"
|
||||
@copy-sql="copySelectedResultSql"
|
||||
@copy-query-sql="copySelectedResultQueries"
|
||||
|
||||
@@ -17,9 +17,11 @@ const props = withDefaults(
|
||||
busy?: boolean;
|
||||
canExportXlsx?: boolean;
|
||||
displayMode?: "tabs" | "list";
|
||||
hasSiblingRunTabs?: boolean;
|
||||
}>(),
|
||||
{
|
||||
displayMode: "tabs",
|
||||
hasSiblingRunTabs: false,
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{
|
||||
@@ -159,7 +161,7 @@ function onListKeydown(event: KeyboardEvent, index: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
<div data-result-set-tabs-region role="group" :aria-label="t('tabs.resultSets')" class="flex h-full min-w-0 items-center gap-1 overflow-hidden" :class="hasSiblingRunTabs ? 'shrink-0 max-w-[50%]' : 'flex-1'">
|
||||
<template v-if="displayMode === 'list'">
|
||||
<div v-if="items.length > 0" class="flex min-w-0 items-center gap-1">
|
||||
<Popover v-if="items.length > 1" v-model:open="open">
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QueryResult, QueryTab } from "@/types/database";
|
||||
|
||||
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
|
||||
vi.mock("@/components/transfer/QueryResultTransferDialog.vue", () => ({ __esModule: true, default: { render: () => null } }));
|
||||
vi.mock("@/components/grid/DataGrid.vue", () => ({ default: { render: () => h("div", { "data-test": "data-grid" }) } }));
|
||||
|
||||
import ContentArea from "../ContentArea.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
cleanups.splice(0).forEach((cleanup) => cleanup());
|
||||
document.body.replaceChildren();
|
||||
window.localStorage?.clear();
|
||||
});
|
||||
|
||||
async function mountContentAreaWithRuns(options: { results: QueryResult[]; resultRuns?: NonNullable<QueryTab["resultRuns"]>; activeResultRunId?: string }) {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const connection = { id: "mysql-1", name: "MySQL", db_type: "mysql" as const, host: "localhost", port: 3306, username: "root", password: "" };
|
||||
useConnectionStore().connections = [connection];
|
||||
const tab = reactive<QueryTab>({
|
||||
id: "query-tab-1",
|
||||
title: "Query 1",
|
||||
connectionId: connection.id,
|
||||
database: "app",
|
||||
mode: "query",
|
||||
sql: "SELECT 1",
|
||||
isExecuting: false,
|
||||
results: options.results,
|
||||
result: options.results[0],
|
||||
activeResultIndex: 0,
|
||||
resultRuns: options.resultRuns,
|
||||
activeResultRunId: options.activeResultRunId,
|
||||
});
|
||||
useQueryStore().tabs.push(tab);
|
||||
|
||||
const state = reactive({ view: "result" as "result" | "messages" });
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ContentArea, {
|
||||
activeTab: tab,
|
||||
activeConnection: connection,
|
||||
activeOutputView: state.view,
|
||||
executableSql: "",
|
||||
formatSqlRequest: null,
|
||||
compressSqlRequest: null,
|
||||
selectedSql: "",
|
||||
cursorPos: 0,
|
||||
resultOnly: true,
|
||||
blockDangerousRedisCommands: false,
|
||||
"onUpdate:activeOutputView": (_tabId: string, view: string) => {
|
||||
state.view = view as typeof state.view;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(pinia);
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
tabs: {
|
||||
resultN: "Result {n}",
|
||||
runN: "Run {n}",
|
||||
resultRuns: "Result runs",
|
||||
resultSets: "Result sets",
|
||||
allResults: "All results ({count})",
|
||||
removeRun: "Remove run {n}",
|
||||
},
|
||||
},
|
||||
},
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
cleanups.push(() => app.unmount());
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
return { host, state, tab };
|
||||
}
|
||||
|
||||
describe("ContentArea result run tabs layout and middle-click close", () => {
|
||||
const singleResult: QueryResult = {
|
||||
columns: ["id"],
|
||||
rows: [[1]],
|
||||
affected_rows: 1,
|
||||
execution_time_ms: 5,
|
||||
};
|
||||
const secondResult: QueryResult = {
|
||||
columns: ["name"],
|
||||
rows: [["Alice"]],
|
||||
affected_rows: 1,
|
||||
execution_time_ms: 3,
|
||||
};
|
||||
|
||||
it("hides ResultSetNavigator when resultRuns exist and active run has a single result set", async () => {
|
||||
const { host } = await mountContentAreaWithRuns({
|
||||
results: [singleResult],
|
||||
resultRuns: [
|
||||
{ id: "run-1", title: "Run 1", sequence: 1, sql: "SELECT 1", createdAt: 1, result: singleResult, results: [singleResult] },
|
||||
{ id: "run-2", title: "Run 2", sequence: 2, sql: "SELECT 2", createdAt: 2, result: singleResult, results: [singleResult] },
|
||||
],
|
||||
activeResultRunId: "run-1",
|
||||
});
|
||||
|
||||
const runTabsRegion = host.querySelector("[data-result-run-tabs-region]");
|
||||
expect(runTabsRegion).not.toBeNull();
|
||||
const runTabs = host.querySelectorAll("[data-result-run-tab]");
|
||||
expect(runTabs.length).toBe(2);
|
||||
|
||||
// ResultSetNavigator is hidden because visibleResultItems.length <= 1
|
||||
const resultSetRegion = host.querySelector("[data-result-set-tabs-region]");
|
||||
expect(resultSetRegion).toBeNull();
|
||||
});
|
||||
|
||||
it("shows ResultSetNavigator when resultRuns exist and active run has multiple result sets", async () => {
|
||||
const { host } = await mountContentAreaWithRuns({
|
||||
results: [singleResult, secondResult],
|
||||
resultRuns: [
|
||||
{ id: "run-1", title: "Run 1", sequence: 1, sql: "SELECT 1; SELECT 2;", createdAt: 1, result: singleResult, results: [singleResult, secondResult] },
|
||||
{ id: "run-2", title: "Run 2", sequence: 2, sql: "SELECT 3", createdAt: 2, result: singleResult, results: [singleResult] },
|
||||
],
|
||||
activeResultRunId: "run-1",
|
||||
});
|
||||
|
||||
const runTabsRegion = host.querySelector("[data-result-run-tabs-region]");
|
||||
expect(runTabsRegion).not.toBeNull();
|
||||
|
||||
// ResultSetNavigator is shown because visibleResultItems.length > 1
|
||||
const resultSetRegion = host.querySelector("[data-result-set-tabs-region]");
|
||||
expect(resultSetRegion).not.toBeNull();
|
||||
expect(resultSetRegion?.classList.contains("shrink-0")).toBe(true);
|
||||
expect(resultSetRegion?.classList.contains("max-w-[50%]")).toBe(true);
|
||||
});
|
||||
|
||||
it("closes result run on middle click on the result run tab", async () => {
|
||||
const { host } = await mountContentAreaWithRuns({
|
||||
results: [singleResult],
|
||||
resultRuns: [
|
||||
{ id: "run-1", title: "Run 1", sequence: 1, sql: "SELECT 1", createdAt: 1, result: singleResult, results: [singleResult] },
|
||||
{ id: "run-2", title: "Run 2", sequence: 2, sql: "SELECT 2", createdAt: 2, result: singleResult, results: [singleResult] },
|
||||
],
|
||||
activeResultRunId: "run-1",
|
||||
});
|
||||
|
||||
const queryStore = useQueryStore();
|
||||
const removeSpy = vi.spyOn(queryStore, "removeResultRun").mockResolvedValue(true);
|
||||
|
||||
const runTabs = host.querySelectorAll<HTMLButtonElement>("[data-result-run-tab]");
|
||||
expect(runTabs.length).toBe(2);
|
||||
|
||||
// Middle click on run-2 (button: 1)
|
||||
const middleClickEvent = new MouseEvent("mousedown", { button: 1, bubbles: true, cancelable: true });
|
||||
runTabs[1]!.dispatchEvent(middleClickEvent);
|
||||
await nextTick();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith("query-tab-1", "run-2");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true, displayMode: "tabs" | "list" = "tabs", activeIndex = 1) {
|
||||
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true, displayMode: "tabs" | "list" = "tabs", activeIndex = 1, hasSiblingRunTabs = false) {
|
||||
// Include a non-tabular result so ordinal and storage index are different.
|
||||
const results = inputResults ?? ([{ columns: [], rows: [] }, ...Array.from({ length: 100 }, (_, i) => ({ columns: ["value"], rows: [[i + 1]], sourceStatement: `SELECT ${i + 1} AS value` }))] as QueryResult[]);
|
||||
const select = vi.fn();
|
||||
@@ -21,7 +21,7 @@ async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true
|
||||
const exportXlsx = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex, active: true, canExportXlsx, displayMode, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
|
||||
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex, active: true, canExportXlsx, displayMode, hasSiblingRunTabs, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
@@ -239,4 +239,19 @@ describe("large result-set navigation", () => {
|
||||
expect(button.textContent?.trim()).toBe("Result sets");
|
||||
expect(button.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses flex-1 when hasSiblingRunTabs is false or omitted", async () => {
|
||||
const { container } = await mountNavigator();
|
||||
const region = container.querySelector("[data-result-set-tabs-region]")!;
|
||||
expect(region.classList.contains("flex-1")).toBe(true);
|
||||
expect(region.classList.contains("shrink-0")).toBe(false);
|
||||
});
|
||||
|
||||
it("constrains width with shrink-0 max-w-[50%] when hasSiblingRunTabs is true", async () => {
|
||||
const { container } = await mountNavigator(undefined, true, "tabs", 1, true);
|
||||
const region = container.querySelector("[data-result-set-tabs-region]")!;
|
||||
expect(region.classList.contains("shrink-0")).toBe(true);
|
||||
expect(region.classList.contains("max-w-[50%]")).toBe(true);
|
||||
expect(region.classList.contains("flex-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user