fix(query): respect result tab display mode in navigator

This commit is contained in:
lxk955
2026-10-09 18:16:08 +08:00
committed by GitHub
parent 192eecb1ec
commit cbe30ef3a2
3 changed files with 186 additions and 60 deletions
@@ -2109,7 +2109,7 @@ defineExpose({
</div>
</div>
</div>
<div v-else-if="showResultRunSelector" class="min-w-0 flex-1">
<div v-else-if="showResultRunSelector" class="min-w-0" :class="{ 'flex-1': visibleResultItems.length === 0 }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
@@ -2148,6 +2148,7 @@ defineExpose({
:can-export-xlsx="activeOutputView === 'result' && redisResultViewMode === 'grid' && !!activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse"
:active-index="activeTab.activeResultIndex ?? 0"
:active="activeOutputView === 'result'"
:display-mode="resultRunDisplayMode"
@select="selectResultItem"
@copy-sql="copySelectedResultSql"
@copy-query-sql="copySelectedResultQueries"
@@ -9,13 +9,19 @@ import { useTabScroll } from "@/composables/useTabScroll";
import type { tabularResultItems } from "@/lib/tabs/tabPresentation";
type ResultItem = ReturnType<typeof tabularResultItems>[number];
const props = defineProps<{
items: ResultItem[];
activeIndex: number;
active: boolean;
busy?: boolean;
canExportXlsx?: boolean;
}>();
const props = withDefaults(
defineProps<{
items: ResultItem[];
activeIndex: number;
active: boolean;
busy?: boolean;
canExportXlsx?: boolean;
displayMode?: "tabs" | "list";
}>(),
{
displayMode: "tabs",
},
);
const emit = defineEmits<{
select: [item: ResultItem];
copySql: [items: ResultItem[]];
@@ -49,6 +55,10 @@ const filteredBatchItems = computed(() => {
return filterResultItems(props.items, batchSearch.value);
});
const selectedBatchItems = computed(() => props.items.filter((item) => selectedIndexes.value.has(item.index)));
// The active result may be a server message that tabularResultItems filters out;
// in that case no tabular item is active and the triggers fall back to the
// generic "result sets" label instead of faking the first item as active.
const activeItem = computed(() => props.items.find((item) => item.index === props.activeIndex));
function revealActive() {
const container = scroller.value;
@@ -64,7 +74,7 @@ function revealActive() {
}
watch(
() => [props.items, props.activeIndex, props.active],
() => [props.items, props.activeIndex, props.active, props.displayMode],
() => nextTick(revealActive),
{ flush: "post", immediate: true },
);
@@ -150,57 +160,122 @@ function onListKeydown(event: KeyboardEvent, index: number) {
<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 class="relative h-full min-w-0 flex-1">
<div ref="scroller" class="result-set-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" @scroll="updateScrollButtons" @wheel="onTabsWheel">
<LightTooltip v-for="item in items" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="active && activeIndex === item.index ? 'default' : 'ghost'" class="h-6 max-w-48 shrink-0 px-2 text-xs" :data-active="active && activeIndex === item.index ? 'true' : undefined" :aria-pressed="active && activeIndex === item.index" @click="select(item)">
<span class="truncate">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
</div>
<div v-if="hasTabOverflow" class="result-set-scrollbar" :class="{ dragging: isScrollbarDragging }" @pointerdown="startScrollbarDrag">
<div class="result-set-scrollbar-thumb" :style="thumbStyle" />
</div>
</div>
<Popover v-if="items.length > 1" v-model:open="open">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs" :title="t('tabs.allResults', { count: items.length })">
{{ t("tabs.allResults", { count: items.length }) }}
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-96 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-1 p-1" @open-auto-focus.prevent="searchInput?.focus()">
<input
ref="searchInput"
v-model="search"
type="search"
class="m-1 shrink-0 rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
:placeholder="t('tabs.searchResults')"
:aria-label="t('tabs.searchResults')"
@keydown="onSearchKeydown"
/>
<div ref="list" class="min-h-0 max-h-72 overflow-y-auto overscroll-contain" :aria-label="t('tabs.resultSets')">
<button
v-for="(item, index) in filteredItems"
:key="item.index"
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
:aria-pressed="active && activeIndex === item.index"
@click="select(item)"
@keydown="onListKeydown($event, index)"
>
<Check class="h-3.5 w-3.5 shrink-0" :class="{ invisible: !active || activeIndex !== item.index }" />
<span class="min-w-0 flex-1">
<span class="block truncate font-medium"
>{{ t("tabs.resultN", { n: item.n }) }}<template v-if="item.label"> · {{ item.label }}</template></span
<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">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-6 max-w-56 shrink-0 gap-1 px-2 text-xs"
:class="{ 'font-semibold text-foreground': active }"
:data-active="active ? 'true' : undefined"
:title="activeItem?.label || activeItem?.title || (activeItem ? t('tabs.resultN', { n: activeItem.n }) : t('tabs.resultSets'))"
:aria-label="activeItem?.displayLabel || activeItem?.label || (activeItem ? t('tabs.resultN', { n: activeItem.n }) : t('tabs.resultSets'))"
>
<span class="truncate">{{ activeItem?.displayLabel || activeItem?.label || (activeItem ? t("tabs.resultN", { n: activeItem.n }) : t("tabs.resultSets")) }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 opacity-70" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-96 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-1 p-1" @open-auto-focus.prevent="searchInput?.focus()">
<input
ref="searchInput"
v-model="search"
type="search"
class="m-1 shrink-0 rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
:placeholder="t('tabs.searchResults')"
:aria-label="t('tabs.searchResults')"
@keydown="onSearchKeydown"
/>
<div ref="list" class="min-h-0 max-h-72 overflow-y-auto overscroll-contain" :aria-label="t('tabs.resultSets')">
<button
v-for="(item, index) in filteredItems"
:key="item.index"
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
:aria-pressed="active && activeIndex === item.index"
@click="select(item)"
@keydown="onListKeydown($event, index)"
>
<span v-if="item.title" class="block truncate text-muted-foreground">{{ item.title }}</span>
</span>
</button>
<p v-if="!filteredItems.length" role="status" class="p-4 text-center text-xs text-muted-foreground">{{ t("tabs.noMatchingResults") }}</p>
<Check class="h-3.5 w-3.5 shrink-0" :class="{ invisible: !active || activeIndex !== item.index }" />
<span class="min-w-0 flex-1">
<span class="block truncate font-medium"
>{{ t("tabs.resultN", { n: item.n }) }}<template v-if="item.label"> · {{ item.label }}</template></span
>
<span v-if="item.title" class="block truncate text-muted-foreground">{{ item.title }}</span>
</span>
</button>
<p v-if="!filteredItems.length" role="status" class="p-4 text-center text-xs text-muted-foreground">{{ t("tabs.noMatchingResults") }}</p>
</div>
</PopoverContent>
</Popover>
<Button
v-else
variant="ghost"
size="sm"
class="h-6 max-w-56 shrink-0 px-2 text-xs"
:class="{ 'font-semibold text-foreground': active }"
:data-active="active ? 'true' : undefined"
:title="activeItem?.label || activeItem?.title || (activeItem ? t('tabs.resultN', { n: activeItem.n }) : t('tabs.resultSets'))"
:aria-label="activeItem?.displayLabel || activeItem?.label || (activeItem ? t('tabs.resultN', { n: activeItem.n }) : t('tabs.resultSets'))"
>
<span class="truncate">{{ activeItem?.displayLabel || activeItem?.label || (activeItem ? t("tabs.resultN", { n: activeItem.n }) : t("tabs.resultSets")) }}</span>
</Button>
</div>
</template>
<template v-else>
<div class="relative h-full min-w-0 flex-1">
<div ref="scroller" class="result-set-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" @scroll="updateScrollButtons" @wheel="onTabsWheel">
<LightTooltip v-for="item in items" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="active && activeIndex === item.index ? 'default' : 'ghost'" class="h-6 max-w-48 shrink-0 px-2 text-xs" :data-active="active && activeIndex === item.index ? 'true' : undefined" :aria-pressed="active && activeIndex === item.index" @click="select(item)">
<span class="truncate">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
</div>
</PopoverContent>
</Popover>
<div v-if="hasTabOverflow" class="result-set-scrollbar" :class="{ dragging: isScrollbarDragging }" @pointerdown="startScrollbarDrag">
<div class="result-set-scrollbar-thumb" :style="thumbStyle" />
</div>
</div>
<Popover v-if="items.length > 1" v-model:open="open">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs" :title="t('tabs.allResults', { count: items.length })">
{{ t("tabs.allResults", { count: items.length }) }}
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-96 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-1 p-1" @open-auto-focus.prevent="searchInput?.focus()">
<input
ref="searchInput"
v-model="search"
type="search"
class="m-1 shrink-0 rounded-md border bg-background px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
:placeholder="t('tabs.searchResults')"
:aria-label="t('tabs.searchResults')"
@keydown="onSearchKeydown"
/>
<div ref="list" class="min-h-0 max-h-72 overflow-y-auto overscroll-contain" :aria-label="t('tabs.resultSets')">
<button
v-for="(item, index) in filteredItems"
:key="item.index"
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
:aria-pressed="active && activeIndex === item.index"
@click="select(item)"
@keydown="onListKeydown($event, index)"
>
<Check class="h-3.5 w-3.5 shrink-0" :class="{ invisible: !active || activeIndex !== item.index }" />
<span class="min-w-0 flex-1">
<span class="block truncate font-medium"
>{{ t("tabs.resultN", { n: item.n }) }}<template v-if="item.label"> · {{ item.label }}</template></span
>
<span v-if="item.title" class="block truncate text-muted-foreground">{{ item.title }}</span>
</span>
</button>
<p v-if="!filteredItems.length" role="status" class="p-4 text-center text-xs text-muted-foreground">{{ t("tabs.noMatchingResults") }}</p>
</div>
</PopoverContent>
</Popover>
</template>
<Popover v-if="items.length > 1" v-model:open="batchOpen">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 gap-1 px-2 text-xs" :title="t('tabs.batchResultActions')">
@@ -12,7 +12,7 @@ afterEach(() => {
document.body.replaceChildren();
});
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true) {
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true, displayMode: "tabs" | "list" = "tabs", activeIndex = 1) {
// 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: 1, active: true, canExportXlsx, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex, active: true, canExportXlsx, displayMode, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
app.use(
createI18n({
legacy: false,
@@ -189,4 +189,54 @@ describe("large result-set navigation", () => {
expect(popup.querySelector('[role="status"]')?.textContent).toBe("No matches");
expect([...popup.querySelectorAll("button")].filter((button) => button.textContent?.includes("Select all") || button.textContent?.includes("Clear")).every((button) => button.disabled)).toBe(true);
});
it("renders a dropdown selector instead of horizontal tabs when displayMode is list", async () => {
const { container, select } = await mountNavigator(undefined, true, "list");
expect(container.querySelector(".result-set-scroll")).toBeNull();
const trigger = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Result 1"))!;
expect(trigger).toBeTruthy();
expect(trigger.querySelector("svg")).toBeTruthy();
// No separate "All results (100)" button in list mode
expect([...container.querySelectorAll("button")].some((button) => button.textContent?.includes("All results"))).toBe(false);
trigger.click();
await nextTick();
await nextTick();
const popup = document.querySelector('[data-slot="popover-content"]')!;
expect(popup).toBeTruthy();
const itemButton = [...popup.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Result 5"))!;
expect(itemButton).toBeTruthy();
itemButton.click();
expect(select).toHaveBeenCalledOnce();
expect(select.mock.calls[0]![0]).toMatchObject({ n: 5, index: 5 });
});
it("renders a single result item without dropdown chevron in list mode when there is only one result", async () => {
const single: QueryResult = { columns: ["id"], rows: [[1]], sourceStatement: "SELECT 1" };
const { container } = await mountNavigator([single], true, "list", 0);
expect(container.querySelector(".result-set-scroll")).toBeNull();
const button = container.querySelector<HTMLButtonElement>("button")!;
expect(button.textContent?.trim()).toBe("Result 1");
// No dropdown chevron
expect(button.querySelector("svg")).toBeNull();
});
it("falls back to the generic label in list mode when the active result is a filtered-out message", async () => {
// activeIndex 0 is the storage index of the non-tabular server message.
const { container } = await mountNavigator(undefined, true, "list", 0);
const trigger = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Result sets"))!;
expect(trigger).toBeTruthy();
expect(trigger.textContent).not.toContain("Result 1");
// The dropdown stays usable and batch actions remain reachable.
expect([...container.querySelectorAll("button")].some((button) => button.textContent?.includes("Batch actions"))).toBe(true);
});
it("falls back to the generic label for a single result whose storage index is not active", async () => {
const single: QueryResult = { columns: ["id"], rows: [[1]], sourceStatement: "SELECT 1" };
const message: QueryResult = { columns: ["Message"], rows: [["notice"]], server_message: true };
const { container } = await mountNavigator([message, single], true, "list", 0);
const button = container.querySelector<HTMLButtonElement>("button")!;
expect(button.textContent?.trim()).toBe("Result sets");
expect(button.querySelector("svg")).toBeNull();
});
});