mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(query): respect result tab display mode in navigator
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user