feat(meilisearch): add document and settings views
This commit is contained in:
1 parent
39ce50b681
commit
884744df4c
32 files changed
+3673
-57
No files matched your search
@@ -2,7 +2,7 @@
|
||||
import { computed, defineComponent, h, nextTick, ref, shallowRef, watch, type VNodeChild } from "vue";
|
||||
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
|
||||
import { ChevronDown, ChevronRight } from "@lucide/vue";
|
||||
import { createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeContainer, isJsonTreeInitiallyExpanded, jsonTreeContainerKind, jsonTreeContainerSummary, type JsonTreeNode } from "@/lib/common/jsonTree";
|
||||
import { createJsonTreeRoot, getJsonTreeChildren, getVisibleJsonTreeNodes, isJsonTreeClosingNode, isJsonTreeContainer, isJsonTreeInitiallyExpanded, jsonTreeContainerKind, jsonTreeContainerSummary, type JsonTreeNode } from "@/lib/common/jsonTree";
|
||||
import { isLosslessJsonNumber } from "@/lib/common/safeJsonFormat";
|
||||
|
||||
defineOptions({ name: "JsonTree" });
|
||||
@@ -137,12 +137,21 @@ function renderJsonNode(node: JsonTreeNode): VNodeChild {
|
||||
|
||||
if (containerValue !== undefined) {
|
||||
const kind = jsonTreeContainerKind(containerValue);
|
||||
rowChildren.push(h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "[" : "{"), h("span", { class: "json-tree-summary" }, jsonTreeContainerSummary(containerValue, expanded)), h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "]" : "}"));
|
||||
rowChildren.push(h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "[" : "{"));
|
||||
if (!expanded) {
|
||||
rowChildren.push(h("span", { class: "json-tree-summary" }, jsonTreeContainerSummary(containerValue)), h("span", { class: `json-tree-bracket is-${kind}` }, kind === "array" ? "]" : "}"));
|
||||
}
|
||||
} else {
|
||||
rowChildren.push(highlightedJsonSpan(scalarClass(node.value), scalarText(node.value)));
|
||||
}
|
||||
|
||||
const children = containerValue !== undefined && expanded ? h("div", { class: "json-tree-children" }, getJsonTreeChildren(node).map(renderJsonNode)) : null;
|
||||
const children =
|
||||
containerValue !== undefined && expanded
|
||||
? h("div", { class: "json-tree-children" }, [
|
||||
...getJsonTreeChildren(node).map(renderJsonNode),
|
||||
h("div", { class: "json-tree-row", style: { paddingInlineStart: indent } }, [h("span", { class: `json-tree-bracket json-tree-closing is-${jsonTreeContainerKind(containerValue)}` }, jsonTreeContainerKind(containerValue) === "array" ? "]" : "}")]),
|
||||
])
|
||||
: null;
|
||||
|
||||
return h("div", { key: node.path, class: "json-tree-node", "data-json-path": node.path }, [h("div", { class: "json-tree-row", style: { paddingInlineStart: indent } }, rowChildren), children]);
|
||||
}
|
||||
@@ -154,7 +163,7 @@ const JsonTreeRoot = defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const visibleNodes = computed(() => getVisibleJsonTreeNodes(rootNode.value, isNodeExpanded));
|
||||
const visibleNodes = computed(() => getVisibleJsonTreeNodes(rootNode.value, isNodeExpanded, props.virtualized));
|
||||
|
||||
function refresh(resetScroll = false) {
|
||||
if (!props.virtualized) return;
|
||||
@@ -180,24 +189,31 @@ defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
|
||||
<DynamicScrollerItem :item="node" :active="active" :size-dependencies="[wordWrap, node.path, node.label, scalarText(node.value), isNodeExpanded(node)]" :data-index="index">
|
||||
<div class="json-tree-node" :data-json-path="node.path">
|
||||
<div class="json-tree-row" :style="{ paddingInlineStart: `${node.depth * 16}px` }">
|
||||
<button v-if="isJsonTreeContainer(node.value)" type="button" class="json-tree-toggle" :aria-expanded="isNodeExpanded(node)" :aria-label="nodeAccessibleLabel(node)" :title="nodeAccessibleLabel(node)" @click="toggleNode(node)">
|
||||
<ChevronDown v-if="isNodeExpanded(node)" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<ChevronRight v-else class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<span v-else class="json-tree-spacer" aria-hidden="true" />
|
||||
|
||||
<template v-if="node.parentKind !== 'root'">
|
||||
<span v-if="node.parentKind === 'array'" class="json-tree-index">[{{ node.label }}]</span>
|
||||
<span v-else class="json-tree-key" v-html="highlightedJson(JSON.stringify(node.label))" />
|
||||
<span class="json-tree-punctuation">:</span>
|
||||
<template v-if="isJsonTreeClosingNode(node)">
|
||||
<span class="json-tree-bracket json-tree-closing" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-if="isJsonTreeContainer(node.value)" type="button" class="json-tree-toggle" :aria-expanded="isNodeExpanded(node)" :aria-label="nodeAccessibleLabel(node)" :title="nodeAccessibleLabel(node)" @click="toggleNode(node)">
|
||||
<ChevronDown v-if="isNodeExpanded(node)" class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<ChevronRight v-else class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<span v-else class="json-tree-spacer" aria-hidden="true" />
|
||||
|
||||
<template v-if="isJsonTreeContainer(node.value)">
|
||||
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "[" : "{" }}</span>
|
||||
<span class="json-tree-summary">{{ jsonTreeContainerSummary(node.value, isNodeExpanded(node)) }}</span>
|
||||
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
|
||||
<template v-if="node.parentKind !== 'root'">
|
||||
<span v-if="node.parentKind === 'array'" class="json-tree-index">[{{ node.label }}]</span>
|
||||
<span v-else class="json-tree-key" v-html="highlightedJson(JSON.stringify(node.label))" />
|
||||
<span class="json-tree-punctuation">:</span>
|
||||
</template>
|
||||
|
||||
<template v-if="isJsonTreeContainer(node.value)">
|
||||
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "[" : "{" }}</span>
|
||||
<template v-if="!isNodeExpanded(node)">
|
||||
<span class="json-tree-summary">{{ jsonTreeContainerSummary(node.value) }}</span>
|
||||
<span class="json-tree-bracket" :class="`is-${jsonTreeContainerKind(node.value)}`">{{ jsonTreeContainerKind(node.value) === "array" ? "]" : "}" }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else :class="scalarClass(node.value)" v-html="highlightedJson(scalarText(node.value))" />
|
||||
</template>
|
||||
<span v-else :class="scalarClass(node.value)" v-html="highlightedJson(scalarText(node.value))" />
|
||||
</div>
|
||||
</div>
|
||||
</DynamicScrollerItem>
|
||||
@@ -241,6 +257,9 @@ defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
|
||||
min-height: 24px;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding-block: 2px;
|
||||
/* Fixed line box so the hover background wraps the glyphs symmetrically. */
|
||||
line-height: 20px;
|
||||
border-radius: var(--dbx-radius-fixed-4);
|
||||
}
|
||||
|
||||
@@ -249,7 +268,7 @@ defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
|
||||
}
|
||||
|
||||
.json-tree-toggle {
|
||||
margin-top: 1px;
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
@@ -275,6 +294,14 @@ defineExpose({ expandAll, collapseAll, resetExpansion, refresh });
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Closing brackets sit in the toggle's slot so they line up with the chevron above. */
|
||||
.json-tree-closing {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.json-tree-key {
|
||||
color: var(--json-tree-key);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zook
|
||||
const ConsulOverview = defineAsyncComponent(() => import("@/components/consul/ConsulOverview.vue"));
|
||||
const ConsulWorkspace = defineAsyncComponent(() => import("@/components/consul/ConsulWorkspace.vue"));
|
||||
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
|
||||
const MeilisearchIndexView = defineAsyncComponent(() => import("@/components/meilisearch/MeilisearchIndexView.vue"));
|
||||
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
|
||||
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
|
||||
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
|
||||
@@ -2092,6 +2093,13 @@ defineExpose({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Meilisearch index detail -->
|
||||
<template v-else-if="activeTab.mode === 'meilisearch'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MeilisearchIndexView :key="activeTab.id" :connection-id="activeTab.connectionId" :index="activeTab.sql" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mongo-gridfs'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" />
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ArrowDown, ArrowUp, Braces, ChevronLeft, ChevronRight, Copy, Download, LayoutGrid, LoaderCircle, Pencil, Save, Search, Table2, Trash2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import JsonTree from "@/components/common/JsonTree.vue";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { compactLocalTimestamp, sanitizeExportBaseName, saveTextFile } from "@/lib/export/saveTextFile";
|
||||
import { parseDocumentStoreJsonDocument, serializeDocumentStoreId, stringifyDocumentStoreValue } from "@/lib/app/documentJsonValues";
|
||||
import { parseJsonPreservingLargeNumbers, safeJsonFormat } from "@/lib/common/safeJsonFormat";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
index: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"refresh-stats": [];
|
||||
}>();
|
||||
|
||||
type ViewMode = "json" | "table" | "grid";
|
||||
/**
|
||||
* Search hit from the backend: `id` is the primary-key value and `formatted` /
|
||||
* `rankingScore` are requested response metadata — all kept outside `document`,
|
||||
* which is always the pure user payload.
|
||||
*/
|
||||
type Hit = { id?: unknown; document: Record<string, any>; formatted?: Record<string, any>; rankingScore?: unknown };
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const q = ref("");
|
||||
const filter = ref("");
|
||||
const sort = ref("");
|
||||
const limit = ref(20);
|
||||
const offset = ref(0);
|
||||
|
||||
const AUTO_REFRESH_INTERVAL_MS = 7_000;
|
||||
const autoRefresh = ref(false);
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const hybridOpen = ref(false);
|
||||
const hybridEnabled = ref(false);
|
||||
const hybridEmbedder = ref("");
|
||||
const hybridSemanticRatio = ref(0.5);
|
||||
const embedders = ref<string[]>([]);
|
||||
|
||||
const showRankingScore = ref(false);
|
||||
const rankingScoreThreshold = ref(0);
|
||||
|
||||
const hits = ref<Hit[]>([]);
|
||||
const totalHits = ref(0);
|
||||
const processingTimeMs = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const viewMode = ref<ViewMode>("json");
|
||||
|
||||
const editOpen = ref(false);
|
||||
const editingHit = ref<Hit | null>(null);
|
||||
const editJson = ref("");
|
||||
const editError = ref("");
|
||||
const isSaving = ref(false);
|
||||
/** True while the canonical document is being fetched for the editor. */
|
||||
const editLoading = ref(false);
|
||||
|
||||
const deleteOpen = ref(false);
|
||||
const deletingHit = ref<Hit | null>(null);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const cellDetailOpen = ref(false);
|
||||
const cellDetailColumn = ref("");
|
||||
const cellDetailValue = ref<unknown>(null);
|
||||
|
||||
function openCellDetail(hit: Hit, column: string) {
|
||||
cellDetailColumn.value = column;
|
||||
cellDetailValue.value = cellValue(hit, column);
|
||||
cellDetailOpen.value = true;
|
||||
}
|
||||
|
||||
/** Plain-text rendering of the cell value with highlight tags removed. */
|
||||
const cellDetailText = computed(() => String(stripSearchMarks(cellDetailValue.value) ?? ""));
|
||||
|
||||
/** Smart-render a cell value: objects/arrays (or JSON strings) as a tree, plain text otherwise. */
|
||||
const cellDetailJson = computed<unknown>(() => {
|
||||
const value = cellDetailValue.value;
|
||||
if (value !== null && typeof value === "object") return value;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return parseJsonPreservingLargeNumbers(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const views = computed<Array<{ value: ViewMode; label: string; icon: Component }>>(() => [
|
||||
{ value: "json", label: t("meilisearch.viewJson"), icon: Braces },
|
||||
{ value: "table", label: t("meilisearch.viewTable"), icon: Table2 },
|
||||
{ value: "grid", label: t("meilisearch.viewGrid"), icon: LayoutGrid },
|
||||
]);
|
||||
|
||||
const page = computed(() => Math.max(0, Math.floor(offset.value / Math.max(1, limit.value))));
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalHits.value / Math.max(1, limit.value))));
|
||||
const canGoNext = computed(() => offset.value + limit.value < totalHits.value);
|
||||
|
||||
const resultSummary = computed(() => t("meilisearch.resultSummary", { count: totalHits.value, time: processingTimeMs.value }));
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function formatCellValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return stringifyDocumentStoreValue(value, "meilisearch");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderMarkHtml(value: string): string {
|
||||
return escapeHtml(value)
|
||||
.replace(/<mark>/g, "<mark>")
|
||||
.replace(/<\/mark>/g, "</mark>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Display value for the JSON/grid cards: prefer the hoisted `formatted` payload
|
||||
* so search `<mark>` highlights stay visible. `document` is already the pure
|
||||
* user payload — no name-based stripping, so user fields named `_formatted` or
|
||||
* `_rankingScore` survive untouched.
|
||||
*/
|
||||
function displayValue(hit: Hit): Record<string, any> {
|
||||
const formatted = hit?.formatted;
|
||||
return formatted && typeof formatted === "object" ? { ...formatted } : { ...hit.document };
|
||||
}
|
||||
|
||||
/** Deep-remove `<mark>` search highlight tags to reconstruct the stored document. */
|
||||
function stripSearchMarks(value: unknown): unknown {
|
||||
if (typeof value === "string") return value.replace(/<\/?mark>/g, "");
|
||||
if (Array.isArray(value)) return value.map(stripSearchMarks);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, stripSearchMarks(entry)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The user payload exactly as returned by the search — already free of dbx/Meilisearch metadata. */
|
||||
function rawDocument(hit: Hit): Record<string, unknown> {
|
||||
return { ...hit.document };
|
||||
}
|
||||
|
||||
function rankingScore(hit: Hit): number | null {
|
||||
const value = hit?.rankingScore;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
const tableColumns = computed(() => {
|
||||
// Columns mirror the stored document fields in their natural order.
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const hit of hits.value) {
|
||||
for (const key of Object.keys(displayValue(hit))) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
|
||||
/** Grid cards show the stored document as flat field rows instead of a JSON tree. */
|
||||
function gridFields(hit: Hit): Array<[string, unknown]> {
|
||||
return Object.entries(displayValue(hit));
|
||||
}
|
||||
|
||||
function cellValue(hit: Hit, column: string): unknown {
|
||||
const formatted = hit?.formatted;
|
||||
if (formatted && typeof formatted === "object" && column in formatted) return formatted[column];
|
||||
return hit?.document?.[column];
|
||||
}
|
||||
|
||||
function isMarkedCell(hit: Hit, column: string): boolean {
|
||||
const value = cellValue(hit, column);
|
||||
return typeof value === "string" && value.includes("<mark>");
|
||||
}
|
||||
|
||||
function markCellHtml(hit: Hit, column: string): string {
|
||||
return renderMarkHtml(String(cellValue(hit, column)));
|
||||
}
|
||||
|
||||
function cellText(hit: Hit, column: string): string {
|
||||
return formatCellValue(cellValue(hit, column));
|
||||
}
|
||||
|
||||
function documentId(hit: Hit | null | undefined): string {
|
||||
const id = hit?.id;
|
||||
if (id == null) return "";
|
||||
// The store-id serializer prefixes string ids so the backend keeps their
|
||||
// type — a string primary key like "123" must not round-trip as numeric 123.
|
||||
return serializeDocumentStoreId(id, "meilisearch");
|
||||
}
|
||||
|
||||
/** Search params shared by the results view and the export. */
|
||||
function buildSearchParams(batchLimit: number, batchOffset: number) {
|
||||
return {
|
||||
q: q.value.trim() || null,
|
||||
filter: filter.value.trim() || null,
|
||||
sort: sort.value.trim() || null,
|
||||
limit: Math.max(1, batchLimit),
|
||||
offset: Math.max(0, batchOffset),
|
||||
hybridEmbedder: hybridEnabled.value && hybridEmbedder.value.trim() ? hybridEmbedder.value.trim() : null,
|
||||
hybridSemanticRatio: hybridEnabled.value ? hybridSemanticRatio.value : null,
|
||||
showRankingScore: showRankingScore.value,
|
||||
rankingScoreThreshold: rankingScoreThreshold.value > 0 ? rankingScoreThreshold.value : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await api.meilisearchSearchDocuments(props.connectionId, props.index, buildSearchParams(limit.value, offset.value));
|
||||
hits.value = result.hits ?? [];
|
||||
totalHits.value = result.totalHits ?? 0;
|
||||
processingTimeMs.value = result.processingTimeMs ?? 0;
|
||||
} catch (e: any) {
|
||||
hits.value = [];
|
||||
totalHits.value = 0;
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
offset.value = Math.max(0, offset.value - limit.value);
|
||||
void runSearch();
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
offset.value = offset.value + limit.value;
|
||||
void runSearch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the editor with the canonical stored document. Search hits may be
|
||||
* partial (`displayedAttributes`) and are never used as the write payload.
|
||||
*/
|
||||
async function startEdit(hit: Hit) {
|
||||
const id = documentId(hit);
|
||||
if (!id) return;
|
||||
editingHit.value = hit;
|
||||
editError.value = "";
|
||||
editJson.value = "";
|
||||
editOpen.value = true;
|
||||
editLoading.value = true;
|
||||
try {
|
||||
const canonicalJson = await api.meilisearchGetDocument(props.connectionId, props.index, id);
|
||||
editJson.value = safeJsonFormat(canonicalJson, 2);
|
||||
} catch (e: any) {
|
||||
editError.value = e?.message || String(e);
|
||||
} finally {
|
||||
editLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Download every hit matching the current search as a JSON file of the stored documents. */
|
||||
const exporting = ref(false);
|
||||
|
||||
const EXPORT_BATCH_SIZE = 1000;
|
||||
|
||||
async function exportResults() {
|
||||
if (exporting.value || totalHits.value === 0) return;
|
||||
if (q.value.trim() || hybridEnabled.value || rankingScoreThreshold.value > 0) {
|
||||
toast(t("meilisearch.exportSearchUnsupported"), 5000);
|
||||
return;
|
||||
}
|
||||
exporting.value = true;
|
||||
try {
|
||||
const documents: Record<string, unknown>[] = [];
|
||||
let batchOffset = 0;
|
||||
while (true) {
|
||||
const page = await api.meilisearchFetchDocuments(props.connectionId, props.index, {
|
||||
filter: filter.value.trim() || null,
|
||||
sort: sort.value.trim() || null,
|
||||
limit: EXPORT_BATCH_SIZE,
|
||||
offset: batchOffset,
|
||||
});
|
||||
const batch = page.documents ?? [];
|
||||
documents.push(...batch);
|
||||
if (batch.length < EXPORT_BATCH_SIZE) break;
|
||||
batchOffset += batch.length;
|
||||
}
|
||||
const content = stringifyDocumentStoreValue(documents, "meilisearch", 2);
|
||||
const baseName = sanitizeExportBaseName(props.index) || "search-results";
|
||||
await saveTextFile(content, `${baseName}-${compactLocalTimestamp()}.json`, "JSON", "json");
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDocument(hit: Hit) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stringifyDocumentStoreValue(rawDocument(hit), "meilisearch", 2));
|
||||
toast(t("meilisearch.copied"));
|
||||
} catch {
|
||||
// Clipboard may be unavailable (e.g. permission denied); stay silent.
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCellDetail() {
|
||||
const value = stripSearchMarks(cellDetailValue.value);
|
||||
const text = value !== null && typeof value === "object" ? stringifyDocumentStoreValue(value, "meilisearch", 2) : String(value ?? "");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast(t("meilisearch.copied"));
|
||||
} catch {
|
||||
// Clipboard may be unavailable (e.g. permission denied); stay silent.
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
const id = documentId(editingHit.value);
|
||||
if (!id) {
|
||||
editError.value = t("meilisearch.invalidJson");
|
||||
return;
|
||||
}
|
||||
const parsed = parseDocumentStoreJsonDocument(editJson.value, "meilisearch");
|
||||
if (!parsed.ok) {
|
||||
editError.value = t("meilisearch.invalidJson");
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
editError.value = "";
|
||||
try {
|
||||
await api.documentUpdateDocument(props.connectionId, "default", props.index, id, editJson.value);
|
||||
toast(t("meilisearch.documentSaved"));
|
||||
editOpen.value = false;
|
||||
emit("refresh-stats");
|
||||
await runSearch();
|
||||
} catch (e: any) {
|
||||
editError.value = e?.message || String(e);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startDelete(hit: Hit) {
|
||||
deletingHit.value = hit;
|
||||
deleteOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const id = documentId(deletingHit.value);
|
||||
if (!id) return;
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await api.documentDeleteDocument(props.connectionId, "default", props.index, id);
|
||||
toast(t("meilisearch.documentDeleted"));
|
||||
deleteOpen.value = false;
|
||||
emit("refresh-stats");
|
||||
await runSearch();
|
||||
// Deleting the last row of a page leaves offset out of range; step back one page.
|
||||
if (!error.value && hits.value.length === 0 && offset.value > 0) {
|
||||
offset.value = Math.max(0, offset.value - limit.value);
|
||||
await runSearch();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAutoRefreshTimer() {
|
||||
if (autoRefreshTimer !== null) {
|
||||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(autoRefresh, (enabled) => {
|
||||
clearAutoRefreshTimer();
|
||||
if (enabled) {
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
if (!loading.value) void runSearch();
|
||||
}, AUTO_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
});
|
||||
|
||||
/** Sortable attributes advertised by the index settings, for the sort picker. */
|
||||
const sortableAttributes = ref<string[]>([]);
|
||||
|
||||
async function loadIndexSettings() {
|
||||
try {
|
||||
const settings = await api.meilisearchGetIndexSettings(props.connectionId, props.index);
|
||||
const configured = settings?.embedders;
|
||||
embedders.value = configured && typeof configured === "object" && !Array.isArray(configured) ? Object.keys(configured) : [];
|
||||
if (!hybridEmbedder.value && embedders.value.length > 0) {
|
||||
hybridEmbedder.value = embedders.value[0];
|
||||
}
|
||||
const sortable = settings?.sortableAttributes;
|
||||
sortableAttributes.value = Array.isArray(sortable) ? sortable.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
// Settings discovery is best-effort; the sort/filter inputs stay free-form.
|
||||
sortableAttributes.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Hover/focus-driven sort picker anchored under the sort input. */
|
||||
const sortPickerOpen = ref(false);
|
||||
let sortPickerCloseTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function openSortPicker() {
|
||||
if (sortPickerCloseTimer !== undefined) {
|
||||
clearTimeout(sortPickerCloseTimer);
|
||||
sortPickerCloseTimer = undefined;
|
||||
}
|
||||
sortPickerOpen.value = true;
|
||||
}
|
||||
|
||||
function scheduleSortPickerClose() {
|
||||
if (sortPickerCloseTimer !== undefined) clearTimeout(sortPickerCloseTimer);
|
||||
// Small delay so the pointer can travel from the input into the panel.
|
||||
sortPickerCloseTimer = setTimeout(() => {
|
||||
sortPickerOpen.value = false;
|
||||
sortPickerCloseTimer = undefined;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
function parseSortEntries(): Array<{ field: string; direction: SortDirection }> {
|
||||
return sort.value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const separator = entry.lastIndexOf(":");
|
||||
const field = (separator >= 0 ? entry.slice(0, separator) : entry).trim();
|
||||
const direction: SortDirection =
|
||||
separator >= 0 &&
|
||||
entry
|
||||
.slice(separator + 1)
|
||||
.trim()
|
||||
.toLowerCase() === "desc"
|
||||
? "desc"
|
||||
: "asc";
|
||||
return { field, direction };
|
||||
})
|
||||
.filter((entry) => entry.field.length > 0);
|
||||
}
|
||||
|
||||
function sortDirectionFor(field: string): SortDirection | null {
|
||||
return parseSortEntries().find((entry) => entry.field === field)?.direction ?? null;
|
||||
}
|
||||
|
||||
/** Toggle `field:direction` in the sort expression; clicking the active direction removes it. */
|
||||
function toggleSortField(field: string, direction: SortDirection) {
|
||||
const entries = parseSortEntries();
|
||||
const index = entries.findIndex((entry) => entry.field === field);
|
||||
if (index >= 0 && entries[index].direction === direction) entries.splice(index, 1);
|
||||
else if (index >= 0) entries[index] = { field, direction };
|
||||
else entries.push({ field, direction });
|
||||
sort.value = entries.map((entry) => `${entry.field}:${entry.direction}`).join(", ");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void runSearch();
|
||||
void loadIndexSettings();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearAutoRefreshTimer();
|
||||
if (sortPickerCloseTimer !== undefined) clearTimeout(sortPickerCloseTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<!-- Search form -->
|
||||
<div class="p-3 border-b shrink-0">
|
||||
<div class="rounded-lg border bg-card p-3 space-y-3">
|
||||
<div class="relative">
|
||||
<Search class="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input v-model="q" class="pl-8" :placeholder="t('meilisearch.keywordPlaceholder')" autofocus @keydown.enter="runSearch" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.filter") }}</Label>
|
||||
<Input v-model="filter" :placeholder="t('meilisearch.filterPlaceholder')" @keydown.enter="runSearch" />
|
||||
</div>
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.sort") }}</Label>
|
||||
<Popover v-model:open="sortPickerOpen">
|
||||
<PopoverAnchor as-child>
|
||||
<Input v-model="sort" class="w-full" :placeholder="t('meilisearch.sortPlaceholder')" @keydown.enter="runSearch" @mouseenter="openSortPicker" @mouseleave="scheduleSortPickerClose" @focus="openSortPicker" />
|
||||
</PopoverAnchor>
|
||||
<PopoverContent side="bottom" align="start" class="w-(--reka-popover-anchor-width) p-0" @open-auto-focus.prevent @mouseenter="openSortPicker" @mouseleave="scheduleSortPickerClose">
|
||||
<p class="border-b px-3 py-2 text-xs text-muted-foreground">{{ t("meilisearch.sortHint") }}</p>
|
||||
<p v-if="sortableAttributes.length === 0" class="px-3 py-2 text-xs text-muted-foreground">{{ t("meilisearch.sortNoAttributes") }}</p>
|
||||
<div v-else class="max-h-56 overflow-y-auto p-1">
|
||||
<div v-for="attr in sortableAttributes" :key="attr" class="flex items-center gap-1 rounded px-2 py-1 hover:bg-muted/50">
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-xs" :title="attr">{{ attr }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :title="t('meilisearch.sortAsc')" @click="toggleSortField(attr, 'asc')">
|
||||
<ArrowUp class="h-3.5 w-3.5" :class="sortDirectionFor(attr) === 'asc' ? 'text-primary' : 'text-muted-foreground/50'" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :title="t('meilisearch.sortDesc')" @click="toggleSortField(attr, 'desc')">
|
||||
<ArrowDown class="h-3.5 w-3.5" :class="sortDirectionFor(attr) === 'desc' ? 'text-primary' : 'text-muted-foreground/50'" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.limit") }}</Label>
|
||||
<Input v-model.number="limit" type="number" min="1" @keydown.enter="runSearch" />
|
||||
</div>
|
||||
<div class="space-y-1 min-w-0">
|
||||
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.offset") }}</Label>
|
||||
<Input v-model.number="offset" type="number" min="0" @keydown.enter="runSearch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<span class="whitespace-nowrap text-xs text-muted-foreground">{{ t("meilisearch.showRankingScore") }}</span>
|
||||
<Switch v-model="showRankingScore" />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<span class="whitespace-nowrap text-xs text-muted-foreground">{{ t("meilisearch.rankingScoreThreshold") }}</span>
|
||||
<Input v-model.number="rankingScoreThreshold" type="number" min="0" max="1" step="0.1" class="h-8 w-16" @keydown.enter="runSearch" />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<span class="whitespace-nowrap text-xs text-muted-foreground">{{ t("meilisearch.autoRefresh") }}</span>
|
||||
<Switch v-model="autoRefresh" />
|
||||
</div>
|
||||
|
||||
<Popover v-model:open="hybridOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8 shrink-0 gap-1.5">
|
||||
{{ t("meilisearch.hybrid") }}
|
||||
<Badge :variant="hybridEnabled ? 'default' : 'secondary'" class="h-4 px-1 text-[10px]">{{ hybridEnabled ? "ON" : "OFF" }}</Badge>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" class="w-64 space-y-3 p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs">{{ t("meilisearch.hybrid") }}</Label>
|
||||
<Switch v-model="hybridEnabled" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.hybridEmbedder") }}</Label>
|
||||
<div v-if="embedders.length > 0" class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="embedder in embedders"
|
||||
:key="embedder"
|
||||
type="button"
|
||||
class="rounded-md border px-2 py-0.5 font-mono text-xs transition-colors disabled:opacity-50"
|
||||
:class="hybridEmbedder === embedder ? 'border-primary/40 bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted/70 hover:text-foreground'"
|
||||
:disabled="!hybridEnabled"
|
||||
@click="hybridEmbedder = embedder"
|
||||
>
|
||||
{{ embedder }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Input v-model="hybridEmbedder" :disabled="!hybridEnabled" placeholder="default" />
|
||||
<p class="text-xs text-muted-foreground">{{ t("meilisearch.hybridNoEmbedders") }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t("meilisearch.hybridSemanticRatio") }}</Label>
|
||||
<span class="text-xs tabular-nums text-foreground/80">{{ hybridSemanticRatio.toFixed(2) }}</span>
|
||||
</div>
|
||||
<input v-model.number="hybridSemanticRatio" type="range" min="0" max="1" step="0.05" :disabled="!hybridEnabled" class="w-full accent-primary" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button size="sm" class="h-8 shrink-0 gap-1" :disabled="loading" @click="runSearch">
|
||||
<LoaderCircle v-if="loading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Search v-else class="h-3.5 w-3.5" />
|
||||
{{ t("meilisearch.search") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result toolbar -->
|
||||
<div class="flex items-center gap-3 px-3 pt-3 shrink-0">
|
||||
<span class="text-sm font-semibold">{{ t("meilisearch.results") }}</span>
|
||||
<div class="grid w-auto grid-cols-3 rounded-md bg-muted/40 p-0.5">
|
||||
<button
|
||||
v-for="view in views"
|
||||
:key="view.value"
|
||||
type="button"
|
||||
class="inline-flex h-5 min-w-0 items-center gap-1 truncate whitespace-nowrap rounded-[5px] px-1.5 text-xs transition-colors"
|
||||
:class="viewMode === view.value ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
@click="viewMode = view.value"
|
||||
>
|
||||
<component :is="view.icon" class="h-3 w-3 shrink-0" />
|
||||
<span>{{ view.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<Button v-if="totalHits > 0" variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground" :disabled="exporting" @click="exportResults">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
{{ t("meilisearch.exportResults") }} ({{ totalHits }})
|
||||
</Button>
|
||||
<span v-if="!loading && !error" class="text-muted-foreground text-xs tabular-nums">{{ resultSummary }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="meilisearch-results flex-1 min-h-0 overflow-y-auto px-3 pb-3 pt-2">
|
||||
<QueryLoadingState v-if="loading" class="h-full" />
|
||||
<ErrorBanner v-else-if="error" :message="error" />
|
||||
|
||||
<div v-else-if="hits.length === 0" class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
{{ t("meilisearch.empty") }}
|
||||
</div>
|
||||
|
||||
<!-- JSON view -->
|
||||
<div v-else-if="viewMode === 'json'" class="space-y-3">
|
||||
<div v-for="(hit, idx) in hits" :key="documentId(hit) || idx" class="group relative rounded-lg border bg-card p-3">
|
||||
<Badge v-if="rankingScore(hit) != null" variant="secondary" class="absolute right-2 bottom-2 tabular-nums" :title="t('meilisearch.showRankingScore')">
|
||||
{{ rankingScore(hit)!.toFixed(2) }}
|
||||
</Badge>
|
||||
<div class="absolute right-2 top-2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7" :title="t('meilisearch.copyDocument')" @click="copyDocument(hit)">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7" :title="t('meilisearch.editDocument')" :disabled="!documentId(hit)" @click="startEdit(hit)">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7 text-destructive" :title="t('meilisearch.deleteDocument')" :disabled="!documentId(hit)" @click="startDelete(hit)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<JsonTree :value="displayValue(hit)" :highlight-json="renderMarkHtml" :initial-expanded-depth="2" class="font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table view -->
|
||||
<div v-else-if="viewMode === 'table'" class="rounded-lg border bg-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full table-fixed text-xs">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/40 text-left text-muted-foreground">
|
||||
<th v-for="column in tableColumns" :key="column" class="px-3 py-2 font-medium">
|
||||
<span class="block truncate" :title="column">{{ column }}</span>
|
||||
</th>
|
||||
<th class="w-24 px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(hit, idx) in hits" :key="documentId(hit) || idx" class="group/row border-b last:border-b-0 hover:bg-muted/30">
|
||||
<td v-for="column in tableColumns" :key="column" class="cursor-pointer px-3 py-2 font-mono" @click="openCellDetail(hit, column)">
|
||||
<div class="truncate">
|
||||
<span v-if="isMarkedCell(hit, column)" v-html="markCellHtml(hit, column)" />
|
||||
<template v-else>{{ cellText(hit, column) }}</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-1">
|
||||
<div class="flex items-center justify-end gap-0.5 opacity-0 transition-opacity group-hover/row:opacity-100">
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('meilisearch.copyDocument')" @click="copyDocument(hit)">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" :title="t('meilisearch.editDocument')" :disabled="!documentId(hit)" @click="startEdit(hit)">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-destructive" :title="t('meilisearch.deleteDocument')" :disabled="!documentId(hit)" @click="startDelete(hit)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid view -->
|
||||
<div v-else class="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<div v-for="(hit, idx) in hits" :key="documentId(hit) || idx" class="group relative rounded-lg border bg-card px-4 py-3.5">
|
||||
<Badge v-if="rankingScore(hit) != null" variant="secondary" class="absolute right-2 bottom-2 tabular-nums" :title="t('meilisearch.showRankingScore')">
|
||||
{{ rankingScore(hit)!.toFixed(2) }}
|
||||
</Badge>
|
||||
<div class="absolute right-2 top-2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7" :title="t('meilisearch.copyDocument')" @click="copyDocument(hit)">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7" :title="t('meilisearch.editDocument')" :disabled="!documentId(hit)" @click="startEdit(hit)">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7 text-destructive" :title="t('meilisearch.deleteDocument')" :disabled="!documentId(hit)" @click="startDelete(hit)">
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<dl class="space-y-2">
|
||||
<div v-for="[key, value] in gridFields(hit)" :key="key" class="flex cursor-pointer items-baseline gap-3 rounded px-1.5 py-1 text-xs hover:bg-muted/40" @click="openCellDetail(hit, key)">
|
||||
<dt class="w-28 shrink-0 truncate text-right text-muted-foreground" :title="key">{{ key }}</dt>
|
||||
<dd class="min-w-0 flex-1 truncate font-mono" :title="formatCellValue(stripSearchMarks(value))">
|
||||
<span v-if="isMarkedCell(hit, key)" v-html="markCellHtml(hit, key)" />
|
||||
<template v-else>{{ formatCellValue(stripSearchMarks(value)) }}</template>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="h-9 flex items-center justify-end gap-1 px-3 border-t shrink-0 text-xs text-muted-foreground">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="offset <= 0" @click="prevPage">
|
||||
<ChevronLeft class="h-3 w-3" />
|
||||
</Button>
|
||||
<span class="tabular-nums">{{ page + 1 }} / {{ totalPages }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="!canGoNext" @click="nextPage">
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Edit dialog -->
|
||||
<Dialog v-model:open="editOpen">
|
||||
<DialogContent class="sm:max-w-2xl h-[70vh] max-h-[70vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("meilisearch.editDocument") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-md border bg-muted/20">
|
||||
<QueryLoadingState v-if="editLoading" class="h-full" />
|
||||
<RedisJsonEditor v-else v-model="editJson" class="h-full" :read-only="isSaving" />
|
||||
</div>
|
||||
<div v-if="editError" class="shrink-0 text-xs text-destructive">{{ editError }}</div>
|
||||
<DialogFooter class="shrink-0">
|
||||
<Button variant="outline" :disabled="isSaving" @click="editOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button class="gap-1" :disabled="isSaving || editLoading || !editJson" @click="saveEdit">
|
||||
<Save class="h-3.5 w-3.5" />
|
||||
{{ t("common.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Cell detail dialog -->
|
||||
<Dialog v-model:open="cellDetailOpen">
|
||||
<DialogContent class="sm:max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2 font-mono text-sm">
|
||||
<span class="min-w-0 truncate">{{ cellDetailColumn }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :title="t('common.copy')" @click="copyCellDetail">
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded-md border bg-muted/20">
|
||||
<JsonTree v-if="cellDetailJson !== undefined" :value="cellDetailJson" :highlight-json="renderMarkHtml" class="p-3 font-mono text-xs" />
|
||||
<div v-else class="whitespace-pre-wrap break-words p-3 font-mono text-xs">{{ cellDetailText }}</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete confirm -->
|
||||
<DangerConfirmDialog v-model:open="deleteOpen" :message="t('meilisearch.deleteDocumentMessage')" :confirm-label="t('meilisearch.deleteDocument')" :loading="isDeleting" @confirm="confirmDelete" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.meilisearch-results :deep(mark) {
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
color: var(--foreground);
|
||||
border-radius: var(--dbx-radius-sm);
|
||||
padding-inline: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import "dayjs/locale/zh-tw";
|
||||
import "dayjs/locale/es";
|
||||
import "dayjs/locale/it";
|
||||
import "dayjs/locale/ja";
|
||||
import "dayjs/locale/ko";
|
||||
import "dayjs/locale/pt-br";
|
||||
import { Copy, FileText, Settings } from "@lucide/vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { MeilisearchIndexOverview } from "@/lib/backend/tauri";
|
||||
import { formatBytes } from "@/lib/database/serverMetrics";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import MeilisearchDocumentsPage from "./MeilisearchDocumentsPage.vue";
|
||||
import MeilisearchSettingsPage from "./MeilisearchSettingsPage.vue";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const DAYJS_LOCALES: Record<string, string> = {
|
||||
"zh-CN": "zh-cn",
|
||||
"zh-TW": "zh-tw",
|
||||
"pt-BR": "pt-br",
|
||||
es: "es",
|
||||
it: "it",
|
||||
ja: "ja",
|
||||
ko: "ko",
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
index: string;
|
||||
}>();
|
||||
|
||||
type ActiveSection = "documents" | "settings";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const activeSection = ref<ActiveSection>("documents");
|
||||
const overview = ref<MeilisearchIndexOverview | null>(null);
|
||||
|
||||
const navSections = computed<Array<{ value: ActiveSection; label: string; icon: typeof FileText }>>(() => [
|
||||
{ value: "documents", label: t("meilisearch.documents"), icon: FileText },
|
||||
{ value: "settings", label: t("meilisearch.settings"), icon: Settings },
|
||||
]);
|
||||
|
||||
const updatedAtLabel = computed(() => {
|
||||
const value = overview.value?.updatedAt;
|
||||
if (!value) return "-";
|
||||
return dayjs(value)
|
||||
.locale(DAYJS_LOCALES[locale.value] ?? "en")
|
||||
.fromNow();
|
||||
});
|
||||
|
||||
async function refreshStats() {
|
||||
try {
|
||||
overview.value = await api.meilisearchGetIndexOverview(props.connectionId, props.index);
|
||||
} catch {
|
||||
// Overview is best-effort; the tab still works without it.
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(value: string | null | undefined) {
|
||||
if (!value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast(t("meilisearch.copied"));
|
||||
} catch {
|
||||
// Clipboard may be unavailable (e.g. permission denied); stay silent.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex overflow-hidden">
|
||||
<!-- Left column: index meta + navigation -->
|
||||
<nav class="w-44 shrink-0 border-r flex flex-col gap-1 overflow-y-auto p-2">
|
||||
<div class="px-1 pb-2">
|
||||
<div class="truncate text-sm font-semibold text-foreground" :title="index">{{ index }}</div>
|
||||
<div v-if="overview?.isIndexing" class="mt-0.5 text-xs text-primary">{{ t("meilisearch.isIndexing") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5 px-1 pb-2 text-xs">
|
||||
<div>
|
||||
<div class="text-muted-foreground">{{ t("meilisearch.uid") }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-1">
|
||||
<span class="min-w-0 truncate font-mono text-foreground/80" :title="overview?.uid ?? index">{{ overview?.uid ?? index }}</span>
|
||||
<button type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('common.copy')" @click="copyText(overview?.uid ?? index)">
|
||||
<Copy class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">{{ t("meilisearch.updatedAt") }}</div>
|
||||
<div class="mt-0.5 text-foreground/80" :title="overview?.updatedAt ?? undefined">{{ updatedAtLabel }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">{{ t("meilisearch.primaryKey") }}</div>
|
||||
<div class="mt-0.5 flex items-center gap-1">
|
||||
<span class="min-w-0 truncate font-mono text-foreground/80" :title="overview?.primaryKey ?? undefined">{{ overview?.primaryKey ?? "-" }}</span>
|
||||
<button v-if="overview?.primaryKey" type="button" class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('common.copy')" @click="copyText(overview.primaryKey)">
|
||||
<Copy class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">{{ t("meilisearch.documentCountLabel") }}</div>
|
||||
<div class="mt-0.5 tabular-nums text-foreground/80">{{ overview ? overview.numberOfDocuments : "-" }}</div>
|
||||
</div>
|
||||
<div v-if="overview?.databaseSize != null">
|
||||
<div class="text-muted-foreground">{{ t("meilisearch.databaseSize") }}</div>
|
||||
<div class="mt-0.5 tabular-nums text-foreground/80">{{ formatBytes(overview.databaseSize) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-1 border-t border-border/60" />
|
||||
|
||||
<button
|
||||
v-for="section in navSections"
|
||||
:key="section.value"
|
||||
type="button"
|
||||
class="mt-1 flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors"
|
||||
:class="activeSection === section.value ? 'bg-accent text-foreground font-medium' : 'text-muted-foreground hover:bg-muted/70 hover:text-foreground'"
|
||||
@click="activeSection = section.value"
|
||||
>
|
||||
<component :is="section.icon" class="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{{ section.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<MeilisearchDocumentsPage v-if="activeSection === 'documents'" :connection-id="connectionId" :index="index" @refresh-stats="refreshStats" />
|
||||
<MeilisearchSettingsPage v-else :connection-id="connectionId" :index="index" @refresh-stats="refreshStats" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Save } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import JsonTree from "@/components/common/JsonTree.vue";
|
||||
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
index: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"refresh-stats": [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
const settingsText = ref("");
|
||||
const settingsValue = ref<Record<string, any>>({});
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const editMode = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const saveError = ref("");
|
||||
|
||||
const clearConfirmOpen = ref(false);
|
||||
const isClearing = ref(false);
|
||||
const deleteIndexConfirmOpen = ref(false);
|
||||
const isDeletingIndex = ref(false);
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const settings = await api.meilisearchGetIndexSettings(props.connectionId, props.index);
|
||||
settingsValue.value = settings ?? {};
|
||||
settingsText.value = JSON.stringify(settingsValue.value, null, 2);
|
||||
} catch (e: any) {
|
||||
settingsValue.value = {};
|
||||
settingsText.value = "{}";
|
||||
error.value = e?.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
saveError.value = "";
|
||||
editMode.value = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
saveError.value = "";
|
||||
editMode.value = false;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(settingsText.value);
|
||||
} catch {
|
||||
saveError.value = t("meilisearch.invalidJson");
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
saveError.value = t("meilisearch.invalidJson");
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
saveError.value = "";
|
||||
try {
|
||||
await api.meilisearchUpdateIndexSettings(props.connectionId, props.index, parsed as Record<string, any>);
|
||||
toast(t("meilisearch.settingsSaved"));
|
||||
settingsValue.value = parsed as Record<string, any>;
|
||||
settingsText.value = JSON.stringify(parsed, null, 2);
|
||||
editMode.value = false;
|
||||
} catch (e: any) {
|
||||
saveError.value = e?.message || String(e);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmClearDocuments() {
|
||||
isClearing.value = true;
|
||||
try {
|
||||
await api.meilisearchDeleteAllDocuments(props.connectionId, props.index);
|
||||
toast(t("meilisearch.documentsCleared"));
|
||||
clearConfirmOpen.value = false;
|
||||
emit("refresh-stats");
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
isClearing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteIndex() {
|
||||
// Capture the owning tab id up front: the delete is awaited, and the user
|
||||
// may switch tabs meanwhile, so reading the active tab afterwards could
|
||||
// close an unrelated tab.
|
||||
const ownerTabId = queryStore.activeTabId;
|
||||
isDeletingIndex.value = true;
|
||||
try {
|
||||
await api.meilisearchDeleteIndex(props.connectionId, props.index);
|
||||
toast(t("meilisearch.indexDeleted"));
|
||||
deleteIndexConfirmOpen.value = false;
|
||||
// The tab may have been closed while the delete was in flight.
|
||||
if (ownerTabId && queryStore.tabs.some((tab) => tab.id === ownerTabId)) queryStore.closeTab(ownerTabId);
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
isDeletingIndex.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto p-3 space-y-3">
|
||||
<QueryLoadingState v-if="loading" class="py-8" />
|
||||
<ErrorBanner v-else-if="error" :message="error" />
|
||||
|
||||
<template v-else>
|
||||
<!-- Settings JSON -->
|
||||
<div class="rounded-lg border bg-card p-3 space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-sm font-medium">{{ t("meilisearch.settingsJson") }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="!editMode">
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("common.edit") }}</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="ghost" size="sm" class="h-6 text-xs" :disabled="isSaving" @click="cancelEdit">{{ t("common.cancel") }}</Button>
|
||||
<Button size="sm" class="h-6 gap-1 text-xs" :disabled="isSaving" @click="saveSettings">
|
||||
<Save class="h-3 w-3" />
|
||||
{{ t("common.save") }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!editMode" class="max-h-[60vh] overflow-auto rounded-md border bg-muted/20 p-3 font-mono text-xs">
|
||||
<JsonTree :value="settingsValue" />
|
||||
</div>
|
||||
<div v-else class="h-[60vh] min-h-0 overflow-hidden rounded-md border bg-muted/20">
|
||||
<RedisJsonEditor v-model="settingsText" class="h-full" :read-only="isSaving" />
|
||||
</div>
|
||||
<div v-if="saveError" class="text-xs text-destructive">{{ saveError }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<div class="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<div class="text-sm font-medium text-destructive">{{ t("meilisearch.dangerZone") }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="destructive" size="sm" class="h-7 text-xs" @click="clearConfirmOpen = true">
|
||||
{{ t("meilisearch.clearDocuments") }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" class="h-7 text-xs" @click="deleteIndexConfirmOpen = true">
|
||||
{{ t("meilisearch.deleteIndex") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<DangerConfirmDialog v-model:open="clearConfirmOpen" :message="t('meilisearch.clearDocumentsConfirmMessage')" :confirm-label="t('meilisearch.clearDocuments')" :loading="isClearing" @confirm="confirmClearDocuments" />
|
||||
<DangerConfirmDialog v-model:open="deleteIndexConfirmOpen" :title="t('meilisearch.deleteIndex')" :message="t('meilisearch.deleteIndexConfirmMessage')" :confirm-label="t('meilisearch.deleteIndex')" :loading="isDeletingIndex" @confirm="confirmDeleteIndex" />
|
||||
</div>
|
||||
</template>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
searchDocuments: vi.fn(),
|
||||
fetchDocuments: vi.fn(),
|
||||
getIndexSettings: vi.fn(),
|
||||
saveTextFile: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
function passthrough(tag: string): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h(tag, attrs, slots.default?.());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@lucide/vue", () => ({
|
||||
ArrowDown: passthrough("span"),
|
||||
ArrowUp: passthrough("span"),
|
||||
Braces: passthrough("span"),
|
||||
ChevronLeft: passthrough("span"),
|
||||
ChevronRight: passthrough("span"),
|
||||
Copy: passthrough("span"),
|
||||
Download: passthrough("span"),
|
||||
LayoutGrid: passthrough("span"),
|
||||
LoaderCircle: passthrough("span"),
|
||||
Pencil: passthrough("span"),
|
||||
Save: passthrough("span"),
|
||||
Search: passthrough("span"),
|
||||
Table2: passthrough("span"),
|
||||
Trash2: passthrough("span"),
|
||||
}));
|
||||
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
|
||||
vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
|
||||
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
|
||||
vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
|
||||
vi.mock("@/components/ui/switch", () => ({ Switch: passthrough("div") }));
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: passthrough("div"),
|
||||
PopoverAnchor: passthrough("div"),
|
||||
PopoverContent: passthrough("div"),
|
||||
PopoverTrigger: passthrough("div"),
|
||||
}));
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: passthrough("div"),
|
||||
DialogContent: passthrough("div"),
|
||||
DialogFooter: passthrough("div"),
|
||||
DialogHeader: passthrough("div"),
|
||||
DialogTitle: passthrough("div"),
|
||||
}));
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/lib/export/saveTextFile", () => ({
|
||||
saveTextFile: mocks.saveTextFile,
|
||||
compactLocalTimestamp: () => "20260101-000000",
|
||||
sanitizeExportBaseName: (value: string) => value,
|
||||
}));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
meilisearchSearchDocuments: mocks.searchDocuments,
|
||||
meilisearchFetchDocuments: mocks.fetchDocuments,
|
||||
meilisearchGetIndexSettings: mocks.getIndexSettings,
|
||||
}));
|
||||
|
||||
import MeilisearchDocumentsPage from "@/components/meilisearch/MeilisearchDocumentsPage.vue";
|
||||
|
||||
let app: ReturnType<typeof createApp> | undefined;
|
||||
let root: HTMLDivElement | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.getIndexSettings.mockResolvedValue({});
|
||||
mocks.saveTextFile.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
app = undefined;
|
||||
root = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("MeilisearchDocumentsPage export", () => {
|
||||
it("uses full-document pagination instead of capped displayed search hits", async () => {
|
||||
const batch = (size: number, from: number) => Array.from({ length: size }, (_, index) => ({ movie_id: from + index, title: `Movie ${from + index}`, internal_notes: `note ${from + index}` }));
|
||||
mocks.searchDocuments.mockResolvedValue({
|
||||
hits: [{ id: 0, document: { movie_id: 0, title: "Movie 0" } }],
|
||||
totalHits: 1000,
|
||||
processingTimeMs: 1,
|
||||
});
|
||||
mocks.fetchDocuments.mockImplementation((_connectionId: string, _index: string, params: { limit: number; offset: number }) => {
|
||||
if (params.offset === 0) return Promise.resolve({ documents: batch(1000, 0), total: 1300 });
|
||||
if (params.offset === 1000) return Promise.resolve({ documents: batch(300, 1000), total: 1300 });
|
||||
return Promise.resolve({ documents: [], total: 1300 });
|
||||
});
|
||||
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(MeilisearchDocumentsPage, { connectionId: "c1", index: "movies" });
|
||||
app.mount(root);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.searchDocuments).toHaveBeenCalledTimes(1));
|
||||
await nextTick();
|
||||
|
||||
const exportButton = Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.includes("meilisearch.exportResults"));
|
||||
expect(exportButton?.textContent).toContain("1000");
|
||||
exportButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.saveTextFile).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(mocks.searchDocuments).toHaveBeenCalledTimes(1);
|
||||
const offsets = mocks.fetchDocuments.mock.calls.map((call) => (call[2] as { offset: number }).offset);
|
||||
expect(offsets).toEqual([0, 1000]);
|
||||
|
||||
const [content, fileName] = mocks.saveTextFile.mock.calls[0] as [string, string];
|
||||
expect(fileName).toBe("movies-20260101-000000.json");
|
||||
const exported = JSON.parse(content) as Array<Record<string, unknown>>;
|
||||
expect(exported).toHaveLength(1300);
|
||||
// Exported entries are the stored documents, without search metadata.
|
||||
expect(exported[0]).toEqual({ movie_id: 0, title: "Movie 0", internal_notes: "note 0" });
|
||||
expect(exported[1299]).toEqual({ movie_id: 1299, title: "Movie 1299", internal_notes: "note 1299" });
|
||||
});
|
||||
});
|
||||
Vendored
+172
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
searchDocuments: vi.fn(),
|
||||
getIndexSettings: vi.fn(),
|
||||
getDocument: vi.fn(),
|
||||
deleteDocument: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
function passthrough(tag: string): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h(tag, attrs, slots.default?.());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders a confirm trigger while open, so tests drive the danger dialog. */
|
||||
function confirmDialog(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
emits: ["confirm", "update:open"],
|
||||
setup(_, { attrs, emit }) {
|
||||
return () => (attrs.open ? h("button", { "data-confirm-dialog": String(attrs.confirmLabel ?? ""), onClick: () => emit("confirm") }, "confirm") : null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Editor stub that exposes the current model value as text for assertions. */
|
||||
function jsonEditor(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs }) {
|
||||
return () => h("pre", { "data-json-editor": "true" }, String(attrs.modelValue ?? ""));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@lucide/vue", () => ({
|
||||
ArrowDown: passthrough("span"),
|
||||
ArrowUp: passthrough("span"),
|
||||
Braces: passthrough("span"),
|
||||
ChevronLeft: passthrough("span"),
|
||||
ChevronRight: passthrough("span"),
|
||||
Copy: passthrough("span"),
|
||||
Download: passthrough("span"),
|
||||
LayoutGrid: passthrough("span"),
|
||||
LoaderCircle: passthrough("span"),
|
||||
Pencil: passthrough("span"),
|
||||
Save: passthrough("span"),
|
||||
Search: passthrough("span"),
|
||||
Table2: passthrough("span"),
|
||||
Trash2: passthrough("span"),
|
||||
}));
|
||||
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
|
||||
vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") }));
|
||||
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
|
||||
vi.mock("@/components/ui/label", () => ({ Label: passthrough("label") }));
|
||||
vi.mock("@/components/ui/switch", () => ({ Switch: passthrough("div") }));
|
||||
vi.mock("@/components/ui/popover", () => ({
|
||||
Popover: passthrough("div"),
|
||||
PopoverAnchor: passthrough("div"),
|
||||
PopoverContent: passthrough("div"),
|
||||
PopoverTrigger: passthrough("div"),
|
||||
}));
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: passthrough("div"),
|
||||
DialogContent: passthrough("div"),
|
||||
DialogFooter: passthrough("div"),
|
||||
DialogHeader: passthrough("div"),
|
||||
DialogTitle: passthrough("div"),
|
||||
}));
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: confirmDialog() }));
|
||||
vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: jsonEditor() }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
meilisearchSearchDocuments: mocks.searchDocuments,
|
||||
meilisearchGetIndexSettings: mocks.getIndexSettings,
|
||||
meilisearchGetDocument: mocks.getDocument,
|
||||
documentDeleteDocument: mocks.deleteDocument,
|
||||
}));
|
||||
|
||||
import MeilisearchDocumentsPage from "@/components/meilisearch/MeilisearchDocumentsPage.vue";
|
||||
|
||||
let app: ReturnType<typeof createApp> | undefined;
|
||||
let root: HTMLDivElement | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.getIndexSettings.mockResolvedValue({});
|
||||
mocks.getDocument.mockResolvedValue('{"movie_id":"123","title":"Alien","internal_notes":"canonical only"}');
|
||||
mocks.deleteDocument.mockResolvedValue(1);
|
||||
// The search hit is partial: it lacks the `internal_notes` field.
|
||||
mocks.searchDocuments.mockResolvedValue({
|
||||
hits: [{ id: "123", document: { movie_id: "123", title: "Alien" } }],
|
||||
totalHits: 1,
|
||||
processingTimeMs: 1,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
app = undefined;
|
||||
root = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function mountPage(): Promise<HTMLDivElement> {
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(MeilisearchDocumentsPage, { connectionId: "c1", index: "movies" });
|
||||
app.mount(root);
|
||||
await vi.waitFor(() => expect(mocks.searchDocuments).toHaveBeenCalledTimes(1));
|
||||
await nextTick();
|
||||
return root;
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, title: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.title === title);
|
||||
if (!button) throw new Error(`button not found: ${title}`);
|
||||
return button;
|
||||
}
|
||||
|
||||
describe("MeilisearchDocumentsPage identity and canonical documents", () => {
|
||||
it("serializes numeric-looking string ids with the store-id serializer on delete", async () => {
|
||||
const container = await mountPage();
|
||||
|
||||
findButton(container, "meilisearch.deleteDocument").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await nextTick();
|
||||
const confirm = container.querySelector("[data-confirm-dialog]");
|
||||
confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.deleteDocument).toHaveBeenCalledTimes(1));
|
||||
// The string id "123" must not round-trip as numeric 123.
|
||||
expect(mocks.deleteDocument).toHaveBeenCalledWith("c1", "default", "movies", '__dbx_meilisearch_string_id__"123"');
|
||||
});
|
||||
|
||||
it("edits the canonical document instead of the partial search hit", async () => {
|
||||
const container = await mountPage();
|
||||
|
||||
findButton(container, "meilisearch.editDocument").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => expect(mocks.getDocument).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.getDocument).toHaveBeenCalledWith("c1", "movies", '__dbx_meilisearch_string_id__"123"');
|
||||
await vi.waitFor(() => {
|
||||
const editor = container.querySelector("[data-json-editor]");
|
||||
expect(editor?.textContent).toContain("internal_notes");
|
||||
});
|
||||
// The editor content comes from the canonical fetch, not the partial hit.
|
||||
const editor = container.querySelector("[data-json-editor]");
|
||||
expect(editor?.textContent).toContain('"title": "Alien"');
|
||||
});
|
||||
|
||||
it("disables edit and delete when the hit has no identity", async () => {
|
||||
mocks.searchDocuments.mockResolvedValue({ hits: [{ document: { title: "No PK" } }], totalHits: 1, processingTimeMs: 1 });
|
||||
const container = await mountPage();
|
||||
|
||||
expect(findButton(container, "meilisearch.editDocument").disabled).toBe(true);
|
||||
expect(findButton(container, "meilisearch.deleteDocument").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type Component } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleteIndex: vi.fn(),
|
||||
getIndexSettings: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
function passthrough(tag: string): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h(tag, attrs, slots.default?.());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Renders a confirm trigger while open, so tests drive the danger dialogs. */
|
||||
function confirmDialog(): Component {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
emits: ["confirm", "update:open"],
|
||||
setup(_, { attrs, emit }) {
|
||||
return () =>
|
||||
attrs.open
|
||||
? h(
|
||||
"button",
|
||||
{
|
||||
"data-confirm-dialog": String(attrs.confirmLabel ?? ""),
|
||||
onClick: () => emit("confirm"),
|
||||
},
|
||||
"confirm",
|
||||
)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@lucide/vue", () => ({ Save: passthrough("span") }));
|
||||
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
|
||||
vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: confirmDialog() }));
|
||||
vi.mock("@/components/ui/ErrorBanner.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/common/QueryLoadingState.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/common/JsonTree.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/components/redis/RedisJsonEditor.vue", () => ({ default: passthrough("div") }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
meilisearchGetIndexSettings: mocks.getIndexSettings,
|
||||
meilisearchDeleteIndex: mocks.deleteIndex,
|
||||
}));
|
||||
|
||||
const store = reactive({
|
||||
activeTabId: "tab-a" as string | null,
|
||||
tabs: [{ id: "tab-a" }] as Array<{ id: string }>,
|
||||
closeTab: mocks.closeTab,
|
||||
});
|
||||
|
||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => store }));
|
||||
|
||||
import MeilisearchSettingsPage from "@/components/meilisearch/MeilisearchSettingsPage.vue";
|
||||
|
||||
let app: ReturnType<typeof createApp> | undefined;
|
||||
let root: HTMLDivElement | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
store.activeTabId = "tab-a";
|
||||
store.tabs = [{ id: "tab-a" }];
|
||||
mocks.getIndexSettings.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
app = undefined;
|
||||
root = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function mountPage(): Promise<HTMLDivElement> {
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(MeilisearchSettingsPage, { connectionId: "c1", index: "movies" });
|
||||
app.mount(root);
|
||||
await vi.waitFor(() => expect(mocks.getIndexSettings).toHaveBeenCalledTimes(1));
|
||||
await nextTick();
|
||||
return root;
|
||||
}
|
||||
|
||||
async function confirmDeleteIndex(container: HTMLDivElement) {
|
||||
const deleteButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.trim() === "meilisearch.deleteIndex");
|
||||
deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await nextTick();
|
||||
const confirmButton = container.querySelector("[data-confirm-dialog]");
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("MeilisearchSettingsPage index deletion", () => {
|
||||
it("closes the tab that started the delete even after switching tabs mid-flight", async () => {
|
||||
let resolveDelete!: () => void;
|
||||
mocks.deleteIndex.mockImplementation(() => new Promise<void>((resolve) => (resolveDelete = resolve)));
|
||||
|
||||
const container = await mountPage();
|
||||
await confirmDeleteIndex(container);
|
||||
await vi.waitFor(() => expect(mocks.deleteIndex).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The user switches to another tab while the delete is in flight.
|
||||
store.tabs.push({ id: "tab-b" });
|
||||
store.activeTabId = "tab-b";
|
||||
|
||||
resolveDelete();
|
||||
await vi.waitFor(() => expect(mocks.closeTab).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(mocks.closeTab).toHaveBeenCalledWith("tab-a");
|
||||
expect(mocks.closeTab).not.toHaveBeenCalledWith("tab-b");
|
||||
});
|
||||
|
||||
it("does not close anything when the owning tab is already gone", async () => {
|
||||
let resolveDelete!: () => void;
|
||||
mocks.deleteIndex.mockImplementation(() => new Promise<void>((resolve) => (resolveDelete = resolve)));
|
||||
|
||||
const container = await mountPage();
|
||||
await confirmDeleteIndex(container);
|
||||
await vi.waitFor(() => expect(mocks.deleteIndex).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The user closed the tab themselves while the delete was in flight.
|
||||
store.tabs = [];
|
||||
store.activeTabId = null;
|
||||
|
||||
resolveDelete();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("meilisearch.indexDeleted"));
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.closeTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -826,8 +826,13 @@ async function toggle(requestId = beginNavigationRequest()) {
|
||||
await connectionStore.loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id);
|
||||
} else if (node.type === "elasticsearch-index" && node.connectionId) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
if (connectionStore.getConfig(node.connectionId)?.db_type === "meilisearch") {
|
||||
const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "meilisearch");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
} else {
|
||||
const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
}
|
||||
} else if (node.type === "vector-collection" && node.connectionId) {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const collectionRef = (node.meta as { collectionId?: string } | undefined)?.collectionId ?? node.label;
|
||||
|
||||
Reference in new issue
Block a user