mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
refactor(query): extract shared result tab popover content
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check, ChevronDown, Copy, FileCode2 } from "@lucide/vue";
|
||||
import { ChevronDown, Copy, FileCode2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { useTabScroll } from "@/composables/useTabScroll";
|
||||
import type { tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import ResultSetNavigatorPopoverContent from "@/components/layout/ResultSetNavigatorPopoverContent.vue";
|
||||
import { filterResultItems, type ResultItem } from "@/components/layout/resultSetNavigator";
|
||||
|
||||
type ResultItem = ReturnType<typeof tabularResultItems>[number];
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ResultItem[];
|
||||
@@ -32,29 +32,16 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
const { t } = useI18n();
|
||||
const scroller = ref<HTMLElement | null>(null);
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const batchSearchInput = ref<HTMLInputElement | null>(null);
|
||||
const open = ref(false);
|
||||
const search = ref("");
|
||||
const batchOpen = ref(false);
|
||||
const batchSearch = ref("");
|
||||
const selectedIndexes = ref<Set<number>>(new Set());
|
||||
const { hasTabOverflow, scrollThumbLeftPercent, scrollThumbWidthPercent, isScrollbarDragging, updateScrollButtons, onTabsWheel, startScrollbarDrag } = useTabScroll(scroller);
|
||||
const thumbStyle = computed(() => ({ left: `${scrollThumbLeftPercent.value}%`, width: `${scrollThumbWidthPercent.value}%` }));
|
||||
function filterResultItems(items: ResultItem[], rawQuery: string) {
|
||||
const query = rawQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return items;
|
||||
// A number is an exact result ordinal, not a substring of every SQL statement.
|
||||
if (/^\d+$/.test(query)) return items.filter((item) => item.n === Number(query));
|
||||
return items.filter((item) => [item.label, item.title, item.result.sourceName, item.result.sourceLabel, item.result.sourceStatement, t("tabs.resultN", { n: item.n })].some((value) => value?.toLocaleLowerCase().includes(query)));
|
||||
}
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
return filterResultItems(props.items, search.value);
|
||||
});
|
||||
const filteredBatchItems = computed(() => {
|
||||
return filterResultItems(props.items, batchSearch.value);
|
||||
return filterResultItems(props.items, batchSearch.value, t);
|
||||
});
|
||||
const selectedBatchItems = computed(() => props.items.filter((item) => selectedIndexes.value.has(item.index)));
|
||||
// The active result may be a server message that tabularResultItems filters out;
|
||||
@@ -80,9 +67,6 @@ watch(
|
||||
() => nextTick(revealActive),
|
||||
{ flush: "post", immediate: true },
|
||||
);
|
||||
watch(open, () => {
|
||||
search.value = "";
|
||||
});
|
||||
|
||||
function select(item: ResultItem) {
|
||||
emit("select", item);
|
||||
@@ -131,33 +115,6 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function focusListItem(index: number) {
|
||||
const buttons = list.value?.querySelectorAll<HTMLButtonElement>("button");
|
||||
if (!buttons?.length) return;
|
||||
buttons[Math.max(0, Math.min(index, buttons.length - 1))]?.focus();
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.isComposing) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
focusListItem(event.key === "ArrowDown" ? 0 : filteredItems.value.length - 1);
|
||||
} else if (event.key === "Enter" && filteredItems.value[0]) {
|
||||
event.preventDefault();
|
||||
select(filteredItems.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function onListKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key === "ArrowUp" && index === 0) {
|
||||
event.preventDefault();
|
||||
searchInput.value?.focus();
|
||||
} else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
focusListItem(event.key === "Home" ? 0 : event.key === "End" ? filteredItems.value.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -179,37 +136,7 @@ function onListKeydown(event: KeyboardEvent, index: number) {
|
||||
<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)"
|
||||
>
|
||||
<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>
|
||||
<ResultSetNavigatorPopoverContent align="start" :items="items" :active-index="activeIndex" :active="active" @select="select" />
|
||||
</Popover>
|
||||
<Button
|
||||
v-else
|
||||
@@ -245,37 +172,7 @@ function onListKeydown(event: KeyboardEvent, index: number) {
|
||||
<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>
|
||||
<ResultSetNavigatorPopoverContent align="end" :items="items" :active-index="activeIndex" :active="active" @select="select" />
|
||||
</Popover>
|
||||
</template>
|
||||
<Popover v-if="items.length > 1" v-model:open="batchOpen">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Check } from "@lucide/vue";
|
||||
import { PopoverContent } from "@/components/ui/popover";
|
||||
import { filterResultItems, type ResultItem } from "@/components/layout/resultSetNavigator";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ResultItem[];
|
||||
activeIndex: number;
|
||||
active: boolean;
|
||||
align?: "start" | "center" | "end";
|
||||
}>(),
|
||||
{
|
||||
align: "start",
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [item: ResultItem];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const list = ref<HTMLElement | null>(null);
|
||||
const search = ref("");
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
return filterResultItems(props.items, search.value, t);
|
||||
});
|
||||
|
||||
function onOpenAutoFocus() {
|
||||
search.value = "";
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
|
||||
function select(item: ResultItem) {
|
||||
emit("select", item);
|
||||
}
|
||||
|
||||
function focusListItem(index: number) {
|
||||
const buttons = list.value?.querySelectorAll<HTMLButtonElement>("button");
|
||||
if (!buttons?.length) return;
|
||||
buttons[Math.max(0, Math.min(index, buttons.length - 1))]?.focus();
|
||||
}
|
||||
|
||||
function onSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.isComposing) return;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
focusListItem(event.key === "ArrowDown" ? 0 : filteredItems.value.length - 1);
|
||||
} else if (event.key === "Enter" && filteredItems.value[0]) {
|
||||
event.preventDefault();
|
||||
select(filteredItems.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function onListKeydown(event: KeyboardEvent, index: number) {
|
||||
if (event.key === "ArrowUp" && index === 0) {
|
||||
event.preventDefault();
|
||||
searchInput.value?.focus();
|
||||
} else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
focusListItem(event.key === "Home" ? 0 : event.key === "End" ? filteredItems.value.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverContent :align="align" class="w-96 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-1 p-1" @open-auto-focus.prevent="onOpenAutoFocus">
|
||||
<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>
|
||||
</template>
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, nextTick, type App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Popover, PopoverTrigger } from "@/components/ui/popover";
|
||||
import ResultSetNavigatorPopoverContent from "../ResultSetNavigatorPopoverContent.vue";
|
||||
import { filterResultItems, type ResultItem } from "../resultSetNavigator";
|
||||
import { tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
let app: App | undefined;
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
async function mountPopoverContent(
|
||||
options: {
|
||||
items?: ResultItem[];
|
||||
activeIndex?: number;
|
||||
active?: boolean;
|
||||
align?: "start" | "center" | "end";
|
||||
} = {},
|
||||
) {
|
||||
const queryResults: QueryResult[] = [
|
||||
{ columns: ["id"], rows: [[1]], sourceName: "first query", sourceStatement: "SELECT 1" },
|
||||
{ columns: ["id"], rows: [[2]], sourceLabel: "second label", sourceStatement: "SELECT 2" },
|
||||
{ columns: ["id"], rows: [[3]], sourceStatement: "SELECT 3" },
|
||||
];
|
||||
const items = options.items ?? tabularResultItems(queryResults);
|
||||
const select = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
|
||||
app = createApp({
|
||||
components: { Popover, PopoverTrigger, ResultSetNavigatorPopoverContent },
|
||||
setup() {
|
||||
return {
|
||||
items,
|
||||
activeIndex: options.activeIndex ?? 0,
|
||||
active: options.active ?? true,
|
||||
align: options.align ?? "start",
|
||||
select,
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<Popover :open="true">
|
||||
<PopoverTrigger><button>Trigger</button></PopoverTrigger>
|
||||
<ResultSetNavigatorPopoverContent
|
||||
:items="items"
|
||||
:active-index="activeIndex"
|
||||
:active="active"
|
||||
:align="align"
|
||||
@select="select"
|
||||
/>
|
||||
</Popover>
|
||||
`,
|
||||
});
|
||||
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
tabs: {
|
||||
resultN: "Result {n}",
|
||||
resultSets: "Result sets",
|
||||
searchResults: "Search",
|
||||
noMatchingResults: "No matches",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const popup = document.querySelector<HTMLElement>('[data-slot="popover-content"]')!;
|
||||
const searchInput = popup.querySelector<HTMLInputElement>('input[type="search"]')!;
|
||||
return { container, popup, searchInput, select, items };
|
||||
}
|
||||
|
||||
describe("ResultSetNavigatorPopoverContent", () => {
|
||||
it("renders result items and shows checkmark on active item", async () => {
|
||||
const { popup } = await mountPopoverContent({ activeIndex: 1, active: true });
|
||||
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
|
||||
expect(buttons).toHaveLength(3);
|
||||
|
||||
expect(buttons[0]?.textContent).toContain("Result 1");
|
||||
expect(buttons[1]?.textContent).toContain("Result 2");
|
||||
expect(buttons[2]?.textContent).toContain("Result 3");
|
||||
|
||||
// Item 1 is active (storage index 1), so button 1's svg should not have 'invisible'
|
||||
const check1 = buttons[1]?.querySelector("svg");
|
||||
expect(check1?.classList.contains("invisible")).toBe(false);
|
||||
|
||||
// Button 0's svg should have 'invisible'
|
||||
const check0 = buttons[0]?.querySelector("svg");
|
||||
expect(check0?.classList.contains("invisible")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters items when typing in search input", async () => {
|
||||
const { popup, searchInput } = await mountPopoverContent();
|
||||
searchInput.value = "second";
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]?.textContent).toContain("Result 2");
|
||||
});
|
||||
|
||||
it("shows empty state when search query matches nothing", async () => {
|
||||
const { popup, searchInput } = await mountPopoverContent();
|
||||
searchInput.value = "nonexistent query";
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
|
||||
expect(buttons).toHaveLength(0);
|
||||
|
||||
const empty = popup.querySelector('[role="status"]');
|
||||
expect(empty?.textContent).toBe("No matches");
|
||||
});
|
||||
|
||||
it("emits select event when clicking an item", async () => {
|
||||
const { popup, select, items } = await mountPopoverContent();
|
||||
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
|
||||
buttons[2]?.click();
|
||||
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(select).toHaveBeenCalledWith(items[2]);
|
||||
});
|
||||
|
||||
it("selects first matching item when pressing Enter in search input", async () => {
|
||||
const { searchInput, select, items } = await mountPopoverContent();
|
||||
searchInput.value = "3";
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
|
||||
searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(select).toHaveBeenCalledOnce();
|
||||
expect(select).toHaveBeenCalledWith(items[2]);
|
||||
});
|
||||
|
||||
it("supports keyboard navigation with arrow keys, Home, and End", async () => {
|
||||
const { popup, searchInput } = await mountPopoverContent();
|
||||
const buttons = popup.querySelectorAll<HTMLButtonElement>('div[aria-label="Result sets"] button');
|
||||
|
||||
// ArrowDown in search input focuses the first item
|
||||
searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
|
||||
// ArrowDown on item moves to next item
|
||||
buttons[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[1]);
|
||||
|
||||
// End moves to last item
|
||||
buttons[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[2]);
|
||||
|
||||
// Home moves to first item
|
||||
buttons[2]?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[0]);
|
||||
|
||||
// ArrowUp on first item returns focus to search input
|
||||
buttons[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
|
||||
expect(document.activeElement).toBe(searchInput);
|
||||
|
||||
// ArrowUp in search input focuses the last item
|
||||
searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }));
|
||||
expect(document.activeElement).toBe(buttons[2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterResultItems helper", () => {
|
||||
it("returns all items when query is empty or whitespace", () => {
|
||||
const queryResults: QueryResult[] = [
|
||||
{ columns: ["id"], rows: [[1]], sourceStatement: "SELECT 1" },
|
||||
{ columns: ["id"], rows: [[2]], sourceStatement: "SELECT 2" },
|
||||
];
|
||||
const items = tabularResultItems(queryResults);
|
||||
const t = (key: string, params?: Record<string, unknown>) => (key === "tabs.resultN" ? `Result ${params?.n}` : key);
|
||||
|
||||
expect(filterResultItems(items, "", t)).toEqual(items);
|
||||
expect(filterResultItems(items, " ", t)).toEqual(items);
|
||||
});
|
||||
|
||||
it("filters by exact ordinal when query is a number", () => {
|
||||
const queryResults: QueryResult[] = Array.from({ length: 15 }, (_, i) => ({
|
||||
columns: ["id"],
|
||||
rows: [[i + 1]],
|
||||
sourceStatement: `SELECT ${i + 1}`,
|
||||
}));
|
||||
const items = tabularResultItems(queryResults);
|
||||
const t = (key: string, params?: Record<string, unknown>) => (key === "tabs.resultN" ? `Result ${params?.n}` : key);
|
||||
|
||||
const matched = filterResultItems(items, "12", t);
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0]?.n).toBe(12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
|
||||
export type ResultItem = ReturnType<typeof tabularResultItems>[number];
|
||||
|
||||
export function filterResultItems(items: ResultItem[], rawQuery: string, t: (key: string, params?: Record<string, unknown>) => string): ResultItem[] {
|
||||
const query = rawQuery.trim().toLocaleLowerCase();
|
||||
if (!query) return items;
|
||||
// A number is an exact result ordinal, not a substring of every SQL statement.
|
||||
if (/^\d+$/.test(query)) return items.filter((item) => item.n === Number(query));
|
||||
return items.filter((item) => [item.label, item.title, item.result.sourceName, item.result.sourceLabel, item.result.sourceStatement, t("tabs.resultN", { n: item.n })].some((value) => value?.toLocaleLowerCase().includes(query)));
|
||||
}
|
||||
Reference in New Issue
Block a user