feat(elasticsearch): add response content search
This commit is contained in:
1 parent
b7606ceee3
commit
939c8d493d
7 files changed
+508
-113
No files matched your search
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Code2, Copy } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
|
||||
import { createShikiJsonHighlighter, type JsonHighlighter } from "@/lib/common/shikiJsonHighlighter";
|
||||
import JsonTree from "./JsonTree.vue";
|
||||
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
|
||||
import { TEXT_CONTENT_SEARCH_MATCH_LIMIT, canFullHighlightTextContent, findTextContentMatches, nextTextContentSearchMatchIndex, renderTextContentMatchesHtml, textContentSearchStatus, type TextContentMatch } from "@/lib/common/textContentSearch";
|
||||
|
||||
const props = defineProps<{
|
||||
status: number;
|
||||
@@ -23,16 +23,22 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const { isDark } = useTheme();
|
||||
const responseView = ref<"raw" | "json">("json");
|
||||
const jsonTreeRef = ref<{ refresh: () => void }>();
|
||||
const jsonHighlighter = ref<JsonHighlighter>();
|
||||
const responsePanelRef = ref<HTMLElement>();
|
||||
const rawPreRef = ref<HTMLPreElement>();
|
||||
const jsonEditorRef = ref<{ openSearch: () => boolean }>();
|
||||
const responseSearchBarRef = ref<{ focusInput: (select?: boolean) => void }>();
|
||||
const responseSearchOpen = ref(false);
|
||||
const responseSearchQuery = ref("");
|
||||
const responseSearchMatchIndex = ref(0);
|
||||
const responseSearchHasNavigated = ref(false);
|
||||
|
||||
const parsedBody = computed(() => {
|
||||
const formattedBody = computed(() => {
|
||||
try {
|
||||
return { valid: true, value: parseJsonPreservingLargeNumbers(props.body) };
|
||||
// Match Redis JSON rendering: a lossless, read-only CodeMirror JSON view.
|
||||
return { valid: true, text: formatJsonSource(props.body, 2) };
|
||||
} catch {
|
||||
return { valid: false, value: null };
|
||||
return { valid: false, text: "" };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -44,18 +50,30 @@ const statusClass = computed(() => {
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => `HTTP ${props.status}`);
|
||||
const jsonAppearance = computed(() => (isDark.value ? "dark" : "light"));
|
||||
const rawSearchMatches = computed(() => findTextContentMatches(props.body, responseSearchQuery.value));
|
||||
const responseSearchActiveIndex = computed(() => {
|
||||
const count = rawSearchMatches.value.length;
|
||||
if (count === 0) return 0;
|
||||
return Math.min(responseSearchMatchIndex.value, count - 1);
|
||||
});
|
||||
const responseSearchLimited = computed(() => rawSearchMatches.value.length >= TEXT_CONTENT_SEARCH_MATCH_LIMIT);
|
||||
const responseSearchStatus = computed(() => textContentSearchStatus(responseSearchActiveIndex.value, rawSearchMatches.value.length, responseSearchLimited.value));
|
||||
const canHighlightRawSearch = computed(() => responseSearchOpen.value && Boolean(responseSearchQuery.value) && canFullHighlightTextContent(props.body.length));
|
||||
const highlightedRawResponse = computed(() => renderTextContentMatchesHtml(props.body, rawSearchMatches.value, { activeMatchIndex: responseSearchActiveIndex.value }));
|
||||
|
||||
watch(
|
||||
() => props.body,
|
||||
() => {
|
||||
responseView.value = parsedBody.value.valid ? "json" : "raw";
|
||||
resetResponseSearch();
|
||||
responseView.value = formattedBody.value.valid ? "json" : "raw";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(responseView, (view) => {
|
||||
if (view === "json") void nextTick(() => jsonTreeRef.value?.refresh());
|
||||
watch(responseSearchQuery, () => {
|
||||
responseSearchMatchIndex.value = 0;
|
||||
responseSearchHasNavigated.value = false;
|
||||
if (responseSearchOpen.value) void scrollResponseSearchMatchIntoView();
|
||||
});
|
||||
|
||||
async function copyResponse() {
|
||||
@@ -67,34 +85,120 @@ async function copyResponse() {
|
||||
}
|
||||
}
|
||||
|
||||
function highlightJson(value: string): string {
|
||||
return jsonHighlighter.value?.(value, jsonAppearance.value) ?? escapeHtml(value);
|
||||
function handleResponsePanelPointerDown() {
|
||||
// The read-only editor manages its own focus. The plain raw response needs a
|
||||
// focus target so the global Cmd/Ctrl+F shortcut can be routed here.
|
||||
if (responseView.value === "raw") responsePanelRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
function focusSearch(): boolean {
|
||||
if (!responsePanelRef.value?.contains(document.activeElement)) return false;
|
||||
|
||||
if (responseView.value === "json") {
|
||||
void nextTick(() => jsonEditorRef.value?.openSearch());
|
||||
return true;
|
||||
}
|
||||
|
||||
responseSearchOpen.value = true;
|
||||
responseSearchHasNavigated.value = false;
|
||||
void nextTick(() => responseSearchBarRef.value?.focusInput(true));
|
||||
return true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void createShikiJsonHighlighter({ appearance: () => jsonAppearance.value })
|
||||
.then((highlight) => {
|
||||
jsonHighlighter.value = highlight;
|
||||
})
|
||||
.catch(() => {
|
||||
jsonHighlighter.value = undefined;
|
||||
});
|
||||
});
|
||||
function resetResponseSearch(restoreFocus = false) {
|
||||
const wasOpen = responseSearchOpen.value;
|
||||
responseSearchOpen.value = false;
|
||||
responseSearchQuery.value = "";
|
||||
responseSearchMatchIndex.value = 0;
|
||||
responseSearchHasNavigated.value = false;
|
||||
// Resetting after a new response or a view switch must not steal focus.
|
||||
if (restoreFocus && wasOpen) void nextTick(() => responsePanelRef.value?.focus({ preventScroll: true }));
|
||||
}
|
||||
|
||||
function closeResponseSearch() {
|
||||
resetResponseSearch(true);
|
||||
}
|
||||
|
||||
function switchResponseView(view: "raw" | "json") {
|
||||
if (responseView.value === view) return;
|
||||
responseView.value = view;
|
||||
if (view !== "raw") resetResponseSearch();
|
||||
}
|
||||
|
||||
function moveResponseSearchMatch(delta: -1 | 1) {
|
||||
const count = rawSearchMatches.value.length;
|
||||
if (count === 0) return;
|
||||
responseSearchMatchIndex.value = nextTextContentSearchMatchIndex(responseSearchActiveIndex.value, delta, count);
|
||||
responseSearchHasNavigated.value = true;
|
||||
void scrollResponseSearchMatchIntoView();
|
||||
}
|
||||
|
||||
function activateResponseSearchMatch(delta: -1 | 1) {
|
||||
if (rawSearchMatches.value.length === 0) return;
|
||||
if (!responseSearchHasNavigated.value) {
|
||||
responseSearchHasNavigated.value = true;
|
||||
void scrollResponseSearchMatchIntoView();
|
||||
return;
|
||||
}
|
||||
moveResponseSearchMatch(delta);
|
||||
}
|
||||
|
||||
async function scrollResponseSearchMatchIntoView() {
|
||||
await nextTick();
|
||||
|
||||
const activeMark = rawPreRef.value?.querySelector<HTMLElement>('[data-document-search-active="true"]');
|
||||
if (activeMark) {
|
||||
activeMark.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
const match = rawSearchMatches.value[responseSearchActiveIndex.value];
|
||||
if (match) scrollRawTextRangeIntoView(match);
|
||||
}
|
||||
|
||||
function scrollRawTextRangeIntoView(match: TextContentMatch) {
|
||||
const pre = rawPreRef.value;
|
||||
const textNode = [...(pre?.childNodes ?? [])].find((node) => node.nodeType === Node.TEXT_NODE);
|
||||
if (!pre || !textNode || typeof document.createRange !== "function") return;
|
||||
|
||||
const start = Math.min(match.start, textNode.textContent?.length ?? 0);
|
||||
const end = Math.min(match.end, textNode.textContent?.length ?? 0);
|
||||
if (end <= start) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, start);
|
||||
range.setEnd(textNode, end);
|
||||
const rangeRect = range.getBoundingClientRect();
|
||||
const preRect = pre.getBoundingClientRect();
|
||||
pre.scrollTop = Math.max(0, pre.scrollTop + rangeRect.top - preRect.top - pre.clientHeight / 2);
|
||||
pre.scrollLeft = Math.max(0, pre.scrollLeft + rangeRect.left - preRect.left - pre.clientWidth / 2);
|
||||
}
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section data-elasticsearch-json-response-root class="flex h-full min-h-0 flex-col bg-background" :aria-label="t('redis.jsonView')">
|
||||
<section ref="responsePanelRef" data-elasticsearch-json-response-root tabindex="-1" class="relative flex h-full min-h-0 flex-col bg-background" :aria-label="t('redis.jsonView')" @pointerdown="handleResponsePanelPointerDown">
|
||||
<TextContentSearchBar
|
||||
v-if="responseSearchOpen"
|
||||
ref="responseSearchBarRef"
|
||||
v-model="responseSearchQuery"
|
||||
:status="responseSearchStatus"
|
||||
:match-count="rawSearchMatches.length"
|
||||
:show-navigation="true"
|
||||
:placeholder="t('editor.search.find')"
|
||||
@activate="activateResponseSearchMatch"
|
||||
@prev="moveResponseSearchMatch(-1)"
|
||||
@next="moveResponseSearchMatch(1)"
|
||||
@close="closeResponseSearch"
|
||||
/>
|
||||
<header class="flex min-h-11 shrink-0 items-center gap-2 border-b bg-muted/25 px-3 py-1.5 text-xs">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm" aria-hidden="true">
|
||||
<Code2 class="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div class="inline-flex h-7 items-center rounded-md border bg-muted/45 p-0.5">
|
||||
<button type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors" :class="responseView === 'raw' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="responseView === 'raw'" @click="responseView = 'raw'">
|
||||
<button type="button" class="h-6 rounded-[4px] px-2 text-xs transition-colors" :class="responseView === 'raw' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" :aria-pressed="responseView === 'raw'" @click="switchResponseView('raw')">
|
||||
{{ t("redis.rawContent") }}
|
||||
</button>
|
||||
<button
|
||||
@@ -102,8 +206,8 @@ onMounted(() => {
|
||||
class="h-6 rounded-[4px] px-2 text-xs transition-colors"
|
||||
:class="responseView === 'json' ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="responseView === 'json'"
|
||||
:disabled="!parsedBody.valid"
|
||||
@click="responseView = 'json'"
|
||||
:disabled="!formattedBody.valid"
|
||||
@click="switchResponseView('json')"
|
||||
>
|
||||
{{ t("redis.jsonView") }}
|
||||
</button>
|
||||
@@ -119,11 +223,34 @@ onMounted(() => {
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</header>
|
||||
<div class="min-h-0 flex-1 overflow-hidden bg-background p-4">
|
||||
<pre v-show="responseView === 'raw' || !parsedBody.valid" class="m-0 h-full overflow-auto bg-transparent p-0 font-mono text-sm leading-6 whitespace-pre">{{ body }}</pre>
|
||||
<div v-if="parsedBody.valid" v-show="responseView === 'json'" class="h-full min-h-0">
|
||||
<JsonTree ref="jsonTreeRef" :value="parsedBody.value" :highlight-json="highlightJson" :virtualized="true" class="dbx-editor-font-family text-sm leading-6" />
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-hidden bg-background">
|
||||
<pre v-if="responseView === 'raw' && canHighlightRawSearch" ref="rawPreRef" class="m-0 h-full overflow-auto bg-transparent p-4 font-mono text-sm leading-6 whitespace-pre" v-html="highlightedRawResponse" />
|
||||
<pre v-else-if="responseView === 'raw'" ref="rawPreRef" class="m-0 h-full overflow-auto bg-transparent p-4 font-mono text-sm leading-6 whitespace-pre">{{ body }}</pre>
|
||||
<RedisJsonEditor v-else-if="formattedBody.valid" ref="jsonEditorRef" :model-value="formattedBody.text" read-only class="min-h-0 flex-1" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.document-search-match) {
|
||||
border-radius: 2px;
|
||||
background: #fde68a;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.document-search-match-active) {
|
||||
background: #f59e0b;
|
||||
color: #111827;
|
||||
outline: 1px solid #d97706;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.document-search-match) {
|
||||
background: #854d0e;
|
||||
}
|
||||
|
||||
:global(.dark) :deep(.document-search-match-active) {
|
||||
background: #fbbf24;
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { ChevronDown, ChevronUp, GripVertical, X } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isTextContentSearchDragSource } from "@/lib/redis/redisValueSearch";
|
||||
import { isTextContentSearchDragSource } from "@/lib/common/textContentSearch";
|
||||
|
||||
/**
|
||||
* Floating find panel (EditorSearchPanel look).
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App, type ComponentPublicInstance } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openJsonSearch: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false } }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h("button", attrs, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/redis/RedisJsonEditor.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: {
|
||||
modelValue: { type: String, required: true },
|
||||
readOnly: { type: Boolean, default: false },
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
expose({ openSearch: mocks.openJsonSearch });
|
||||
return () => h("div", { "data-redis-json-editor-stub": "", "data-read-only": String(props.readOnly) }, props.modelValue);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/common/TextContentSearchBar.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: {
|
||||
modelValue: { type: String, required: true },
|
||||
status: { type: String, required: true },
|
||||
matchCount: { type: Number, required: true },
|
||||
},
|
||||
emits: ["update:modelValue", "activate", "prev", "next", "close"],
|
||||
setup(props, { emit, expose }) {
|
||||
const input = ref<HTMLInputElement>();
|
||||
expose({ focusInput: () => input.value?.focus() });
|
||||
return () =>
|
||||
h("div", { "data-elasticsearch-response-search": "" }, [
|
||||
h("input", {
|
||||
ref: input,
|
||||
"data-elasticsearch-response-search-input": "",
|
||||
value: props.modelValue,
|
||||
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
|
||||
}),
|
||||
h("span", { "data-elasticsearch-response-search-status": "" }, props.status),
|
||||
h("button", { "data-elasticsearch-response-search-prev": "", onClick: () => emit("prev") }),
|
||||
h("button", { "data-elasticsearch-response-search-next": "", onClick: () => emit("next") }),
|
||||
h("button", { "data-elasticsearch-response-search-close": "", onClick: () => emit("close") }),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import ElasticsearchJsonResponsePanel from "@/components/common/ElasticsearchJsonResponsePanel.vue";
|
||||
|
||||
type SearchablePanel = ComponentPublicInstance & { focusSearch: () => boolean };
|
||||
|
||||
let app: App<Element> | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
|
||||
async function flushUi() {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
async function mountResponsePanel(body = '{"first":"needle","second":"<needle>"}', raw = true) {
|
||||
const panelRef = ref<SearchablePanel>();
|
||||
const bodyRef = ref(body);
|
||||
root = document.createElement("div");
|
||||
document.body.appendChild(root);
|
||||
app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
return () => h(ElasticsearchJsonResponsePanel, { ref: panelRef, status: 200, body: bodyRef.value });
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(root);
|
||||
await flushUi();
|
||||
|
||||
const panelElement = root.querySelector<HTMLElement>("[data-elasticsearch-json-response-root]");
|
||||
const rawButton = [...root.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent === "redis.rawContent");
|
||||
if (!panelElement || !rawButton || !panelRef.value) throw new Error("Failed to mount Elasticsearch response panel");
|
||||
if (raw) {
|
||||
rawButton.click();
|
||||
await flushUi();
|
||||
}
|
||||
return {
|
||||
panel: panelRef.value,
|
||||
panelElement,
|
||||
async setBody(nextBody: string) {
|
||||
bodyRef.value = nextBody;
|
||||
await flushUi();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = null;
|
||||
root?.remove();
|
||||
root = null;
|
||||
mocks.openJsonSearch.mockClear();
|
||||
});
|
||||
|
||||
describe("ElasticsearchJsonResponsePanel search", () => {
|
||||
it("uses the read-only Redis JSON editor for formatted JSON responses", async () => {
|
||||
const { panel, panelElement } = await mountResponsePanel('{"name":"Ada"}', false);
|
||||
const editor = root!.querySelector<HTMLElement>("[data-redis-json-editor-stub]");
|
||||
|
||||
expect(editor?.getAttribute("data-read-only")).toBe("true");
|
||||
expect(editor?.textContent).toBe('{\n "name": "Ada"\n}');
|
||||
|
||||
panelElement.focus();
|
||||
expect(panel.focusSearch()).toBe(true);
|
||||
await flushUi();
|
||||
expect(mocks.openJsonSearch).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("opens a find panel only while the response has focus, highlights literal matches, and navigates them", async () => {
|
||||
const { panel, panelElement } = await mountResponsePanel();
|
||||
|
||||
expect(panel.focusSearch()).toBe(false);
|
||||
panelElement.focus();
|
||||
expect(panel.focusSearch()).toBe(true);
|
||||
await flushUi();
|
||||
|
||||
const input = root!.querySelector<HTMLInputElement>("[data-elasticsearch-response-search-input]");
|
||||
expect(input).not.toBeNull();
|
||||
expect(document.activeElement).toBe(input);
|
||||
input!.value = "needle";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushUi();
|
||||
|
||||
expect(root!.querySelector("[data-elasticsearch-response-search-status]")?.textContent).toBe("1/2");
|
||||
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(2);
|
||||
expect(root!.querySelector('[data-document-search-active="true"]')?.getAttribute("data-document-search-match")).toBe("0");
|
||||
expect(root!.querySelector("needle")).toBeNull();
|
||||
|
||||
root!.querySelector<HTMLButtonElement>("[data-elasticsearch-response-search-next]")!.click();
|
||||
await flushUi();
|
||||
expect(root!.querySelector('[data-document-search-active="true"]')?.getAttribute("data-document-search-match")).toBe("1");
|
||||
});
|
||||
|
||||
it("clears search state when closed", async () => {
|
||||
const { panel, panelElement } = await mountResponsePanel();
|
||||
panelElement.focus();
|
||||
panel.focusSearch();
|
||||
await flushUi();
|
||||
|
||||
const input = root!.querySelector<HTMLInputElement>("[data-elasticsearch-response-search-input]")!;
|
||||
input.value = "needle";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushUi();
|
||||
root!.querySelector<HTMLButtonElement>("[data-elasticsearch-response-search-close]")!.click();
|
||||
await flushUi();
|
||||
|
||||
expect(root!.querySelector("[data-elasticsearch-response-search]")).toBeNull();
|
||||
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(0);
|
||||
expect(document.activeElement).toBe(panelElement);
|
||||
});
|
||||
|
||||
it("resets an open raw search when the response changes", async () => {
|
||||
const { panel, panelElement, setBody } = await mountResponsePanel();
|
||||
panelElement.focus();
|
||||
panel.focusSearch();
|
||||
await flushUi();
|
||||
|
||||
const input = root!.querySelector<HTMLInputElement>("[data-elasticsearch-response-search-input]")!;
|
||||
input.value = "needle";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushUi();
|
||||
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(2);
|
||||
|
||||
await setBody('{"fresh":"response"}');
|
||||
expect(root!.querySelector("[data-elasticsearch-response-search]")).toBeNull();
|
||||
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(0);
|
||||
expect(root!.querySelector("[data-redis-json-editor-stub]")?.textContent).toBe('{\n "fresh": "response"\n}');
|
||||
});
|
||||
|
||||
it("keeps invalid response bodies searchable in the raw view", async () => {
|
||||
const invalid = await mountResponsePanel("not JSON", false);
|
||||
expect([...root!.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent === "redis.jsonView")?.disabled).toBe(true);
|
||||
invalid.panelElement.focus();
|
||||
expect(invalid.panel.focusSearch()).toBe(true);
|
||||
await flushUi();
|
||||
expect(root!.querySelector("[data-elasticsearch-response-search-input]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("searches large raw responses without rendering all highlight nodes", async () => {
|
||||
const large = await mountResponsePanel(`${"x".repeat(256_001)}needle`, false);
|
||||
large.panelElement.focus();
|
||||
large.panel.focusSearch();
|
||||
await flushUi();
|
||||
const input = root!.querySelector<HTMLInputElement>("[data-elasticsearch-response-search-input]")!;
|
||||
input.value = "needle";
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushUi();
|
||||
|
||||
expect(root!.querySelector("[data-elasticsearch-response-search-status]")?.textContent).toBe("1/1");
|
||||
expect(root!.querySelectorAll(".document-search-match")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,10 @@ type SearchableBrowserHandle = {
|
||||
executeCommand?: (command: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
type ElasticsearchJsonResponsePanelHandle = {
|
||||
focusSearch: () => boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
activeTab: QueryTab;
|
||||
activeConnection?: ConnectionConfig;
|
||||
@@ -210,6 +214,7 @@ const columnInfoLoading = ref(false);
|
||||
const columnInfoError = ref<string | undefined>(undefined);
|
||||
const dataGridRef = ref<DataGridHandle>();
|
||||
const queryEditorRef = ref<InstanceType<typeof QueryEditor>>();
|
||||
const elasticsearchJsonResponsePanelRef = ref<ElasticsearchJsonResponsePanelHandle>();
|
||||
const tableStructureEditorRef = ref<{ applyChanges: () => Promise<boolean> }>();
|
||||
const standaloneResultToolbarRef = ref<HTMLElement | null>(null);
|
||||
const standaloneResultToolbarWidth = ref(0);
|
||||
@@ -755,6 +760,7 @@ function onHandleCloseColumnPanel() {
|
||||
}
|
||||
|
||||
function focusSearch(): boolean {
|
||||
if (elasticsearchJsonResponsePanelRef.value?.focusSearch()) return true;
|
||||
if (props.activeTab.mode === "mongo") return documentBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
|
||||
@@ -1409,8 +1415,8 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
|
||||
<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" class="flex-1 min-h-0" :status="200" :body="activeElasticsearchRawBody" can-show-table @show-table="showElasticsearchRawJson = false" />
|
||||
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" ref="elasticsearchJsonResponsePanelRef" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
|
||||
<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" ref="elasticsearchJsonResponsePanelRef" class="flex-1 min-h-0" :status="200" :body="activeElasticsearchRawBody" can-show-table @show-table="showElasticsearchRawJson = false" />
|
||||
<DataGrid
|
||||
v-else-if="activeTab.result && hasTabularResult"
|
||||
ref="dataGridRef"
|
||||
|
||||
Reference in new issue
Block a user