feat(mongo): add foldable document preview
This commit is contained in:
6 files changed
+83
-226
No files matched your search
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue";
|
||||
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -82,7 +82,6 @@ import {
|
||||
} from "@/lib/mongo/mongoDocumentValues";
|
||||
import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement";
|
||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { findDocumentTextMatches, renderDocumentJsonHtml } from "@/lib/document/documentJsonSearch";
|
||||
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
|
||||
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
|
||||
@@ -146,12 +145,7 @@ const sortInputRef = ref<HTMLTextAreaElement>();
|
||||
const dataGridRef = ref<InstanceType<typeof DataGrid>>();
|
||||
const viewOptionsOpen = ref(false);
|
||||
const mongoUpdateTarget = computed(() => (props.databaseType === "mongodb" && mongoCopyDocumentsAvailable.value ? { collection: props.collection, idColumn: "_id" as const } : undefined));
|
||||
const documentViewerRef = ref<HTMLElement>();
|
||||
const documentSearchInputRef = ref<HTMLInputElement>();
|
||||
const documentSearchOpen = ref(false);
|
||||
const documentSearchQuery = ref("");
|
||||
const documentSearchMatchIndex = ref(0);
|
||||
const documentSearchHasNavigated = ref(false);
|
||||
const documentJsonEditorRef = ref<{ openSearch: () => boolean }>();
|
||||
const documentViewerSearchActive = ref(false);
|
||||
|
||||
function openDataGridExtractorConfiguration() {
|
||||
@@ -285,16 +279,6 @@ const selectedDocumentIdLabel = computed(() => {
|
||||
return typeof id === "object" ? stringifyDocumentStoreValue(id, documentStoreProvider.value.kind) : String(id);
|
||||
});
|
||||
const selectedDocumentIdWidth = computed(() => `${Math.min(Math.max(Array.from(selectedDocumentIdLabel.value).length + 2, 5), 52)}ch`);
|
||||
const documentSearchText = computed(() => editJson.value);
|
||||
const documentSearchMatches = computed(() => findDocumentTextMatches(documentSearchText.value, documentSearchQuery.value));
|
||||
const documentSearchActiveIndex = computed(() => {
|
||||
if (documentSearchMatches.value.length === 0) return 0;
|
||||
return Math.min(documentSearchMatchIndex.value, documentSearchMatches.value.length - 1);
|
||||
});
|
||||
const documentSearchStatus = computed(() => {
|
||||
const count = documentSearchMatches.value.length;
|
||||
return count > 0 ? `${documentSearchActiveIndex.value + 1}/${count}` : "0/0";
|
||||
});
|
||||
|
||||
const editKeyWidth = computed(() => {
|
||||
const longest = editFields.value.reduce((max, field) => {
|
||||
@@ -1591,15 +1575,12 @@ function docPreview(doc: JsonRecord): string {
|
||||
return `${id} - ${preview}`;
|
||||
}
|
||||
|
||||
function highlightedJson(json: string): string {
|
||||
return renderDocumentJsonHtml(json, documentSearchOpen.value ? documentSearchQuery.value : "", documentSearchActiveIndex.value);
|
||||
}
|
||||
|
||||
function handleDocumentViewerDoubleClick(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const jsonViewer = target.closest(".json-viewer");
|
||||
if (jsonViewer && target !== jsonViewer) return;
|
||||
// CodeMirror uses .cm-line for rendered document content. Preserve the
|
||||
// existing shortcut only for whitespace around the source, not text itself.
|
||||
if (target.closest(".cm-line")) return;
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed && selection.toString()) return;
|
||||
startEdit();
|
||||
@@ -1614,55 +1595,12 @@ function focusSearch(): boolean {
|
||||
if (viewMode.value !== "document" || !documentViewerSearchActive.value) return false;
|
||||
if (isEditing.value) return false;
|
||||
if (!isNew.value && selectedIdx.value === null) return false;
|
||||
documentSearchOpen.value = true;
|
||||
documentSearchHasNavigated.value = false;
|
||||
void nextTick(() => {
|
||||
documentSearchInputRef.value?.focus();
|
||||
documentSearchInputRef.value?.select();
|
||||
});
|
||||
return true;
|
||||
return documentJsonEditorRef.value?.openSearch() ?? false;
|
||||
}
|
||||
|
||||
function closeDocumentSearch() {
|
||||
documentSearchOpen.value = false;
|
||||
void nextTick(() => {
|
||||
documentViewerRef.value?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function moveDocumentSearchMatch(delta: -1 | 1) {
|
||||
const count = documentSearchMatches.value.length;
|
||||
if (count === 0) return;
|
||||
documentSearchMatchIndex.value = (documentSearchActiveIndex.value + delta + count) % count;
|
||||
documentSearchHasNavigated.value = true;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
}
|
||||
|
||||
function activateDocumentSearchMatch(delta: -1 | 1) {
|
||||
if (documentSearchMatches.value.length === 0) return;
|
||||
if (!documentSearchHasNavigated.value) {
|
||||
documentSearchHasNavigated.value = true;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
return;
|
||||
}
|
||||
moveDocumentSearchMatch(delta);
|
||||
}
|
||||
|
||||
async function scrollDocumentSearchMatchIntoView() {
|
||||
await nextTick();
|
||||
documentViewerRef.value?.querySelector<HTMLElement>('[data-document-search-active="true"]')?.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
}
|
||||
|
||||
watch([documentSearchQuery, documentSearchText], () => {
|
||||
documentSearchMatchIndex.value = 0;
|
||||
documentSearchHasNavigated.value = false;
|
||||
void scrollDocumentSearchMatchIntoView();
|
||||
});
|
||||
|
||||
watch([viewMode, isEditing, selectedIdx], ([mode, editing, index]) => {
|
||||
if (mode === "document" && !editing && index !== null) return;
|
||||
documentViewerSearchActive.value = false;
|
||||
documentSearchOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -2255,23 +2193,6 @@ defineExpose({ focusSearch });
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="documentSearchOpen && (!isEditing || documentEditMode === 'json')" data-document-search class="flex h-9 shrink-0 items-center justify-end gap-1 border-b bg-background px-2">
|
||||
<div class="relative w-56 max-w-[45%] min-w-32">
|
||||
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input ref="documentSearchInputRef" v-model="documentSearchQuery" class="h-7 select-text pl-7 pr-2 text-xs" :placeholder="t('editor.search.find')" @keydown.enter.prevent="activateDocumentSearchMatch($event.shiftKey ? -1 : 1)" @keydown.escape.prevent="closeDocumentSearch" />
|
||||
</div>
|
||||
<span class="w-12 text-center text-[11px] tabular-nums text-muted-foreground">{{ documentSearchStatus }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.prevMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(-1)">
|
||||
<ChevronUp class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.nextMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(1)">
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="closeDocumentSearch">
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditing && documentEditMode === 'json' && !isNew" class="px-4 py-1.5 text-[11px] text-muted-foreground border-b bg-muted/20 shrink-0">
|
||||
{{ t("mongo.jsonReplaceHint") }}
|
||||
</div>
|
||||
@@ -2293,8 +2214,8 @@ defineExpose({ focusSearch });
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else ref="documentViewerRef" data-document-json-viewer tabindex="-1" class="flex-1 overflow-auto bg-muted/10 outline-none" @dblclick="handleDocumentViewerDoubleClick">
|
||||
<pre class="json-viewer min-w-fit select-text p-5" :style="documentFontStyle" v-html="highlightedJson(editJson)" />
|
||||
<div v-else data-document-json-viewer class="flex-1 min-h-0 bg-muted/10 outline-none" @dblclick="handleDocumentViewerDoubleClick">
|
||||
<RedisJsonEditor ref="documentJsonEditorRef" :model-value="editJson" read-only :line-numbers="false" presentation="viewer" class="h-full" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
@@ -2319,15 +2240,6 @@ defineExpose({ focusSearch });
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.json-viewer {
|
||||
font-family: var(--dbx-editor-font-family);
|
||||
font-size: var(--dbx-editor-font-size);
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.json-edit {
|
||||
font-family: var(--dbx-editor-font-family);
|
||||
font-size: var(--dbx-editor-font-size);
|
||||
|
||||
+32
-3
@@ -11,6 +11,10 @@ const backend = vi.hoisted(() => ({
|
||||
documentDeleteDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
const documentJsonEditor = vi.hoisted(() => ({
|
||||
openSearch: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const settings = vi.hoisted(() => ({
|
||||
editorSettings: {
|
||||
pageSize: 100,
|
||||
@@ -106,6 +110,24 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
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 },
|
||||
lineNumbers: { type: Boolean, default: true },
|
||||
presentation: { type: String, default: "editor" },
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
expose({ openSearch: documentJsonEditor.openSearch });
|
||||
return () => h("div", { "data-redis-json-editor-stub": "", "data-read-only": String(props.readOnly), "data-line-numbers": String(props.lineNumbers), "data-presentation": props.presentation }, [h("div", { class: "cm-line" }, h("span", { class: "json-string" }, props.modelValue))]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/popover", async () => {
|
||||
const { computed, defineComponent, h, inject, provide } = await import("vue");
|
||||
type PopoverContext = {
|
||||
@@ -236,6 +258,7 @@ beforeEach(async () => {
|
||||
backend.cancelQuery.mockReset();
|
||||
backend.ensureConnected.mockReset();
|
||||
backend.documentDeleteDocument.mockReset();
|
||||
documentJsonEditor.openSearch.mockClear();
|
||||
backend.documentDeleteDocument.mockResolvedValue(undefined);
|
||||
settings.editorSettings.mongoViewMode = "table";
|
||||
settings.editorSettings.columnWidthDensity = "standard";
|
||||
@@ -532,7 +555,7 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
collection: "typed_ids",
|
||||
databaseType: "mongodb",
|
||||
});
|
||||
app.mount(root!);
|
||||
const documentBrowser = app.mount(root!) as unknown as { focusSearch: () => boolean };
|
||||
await flushUi();
|
||||
|
||||
const documentRow = [...root!.querySelectorAll<HTMLElement>(".group")].find((element) => element.textContent?.includes("document-1"))!;
|
||||
@@ -540,7 +563,7 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
await flushUi();
|
||||
|
||||
const viewer = root!.querySelector<HTMLElement>("[data-document-json-viewer]")!;
|
||||
const jsonText = viewer.querySelector<HTMLElement>(".json-string")!;
|
||||
const jsonText = viewer.querySelector<HTMLElement>(".cm-line .json-string")!;
|
||||
const documentId = root!.querySelector<HTMLInputElement>('input[aria-label^="_id:"]')!;
|
||||
expect(root!.firstElementChild?.classList.contains("select-none")).toBe(true);
|
||||
expect(documentId.readOnly).toBe(true);
|
||||
@@ -550,7 +573,9 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
expect(documentIdBadge).not.toBeNull();
|
||||
expect(documentIdBadge?.classList.contains("rounded")).toBe(true);
|
||||
expect(documentIdBadge?.classList.contains("rounded-4xl")).toBe(false);
|
||||
expect(viewer.querySelector(".json-viewer")?.classList.contains("select-text")).toBe(true);
|
||||
expect(viewer.querySelector<HTMLElement>("[data-redis-json-editor-stub]")?.dataset.readOnly).toBe("true");
|
||||
expect(viewer.querySelector<HTMLElement>("[data-redis-json-editor-stub]")?.dataset.lineNumbers).toBe("false");
|
||||
expect(viewer.querySelector<HTMLElement>("[data-redis-json-editor-stub]")?.dataset.presentation).toBe("viewer");
|
||||
|
||||
documentId.setSelectionRange(0, documentId.value.length);
|
||||
expect(documentId.selectionStart).toBe(0);
|
||||
@@ -561,6 +586,10 @@ describe("DocumentBrowser MongoDB filter value types", () => {
|
||||
await flushUi();
|
||||
expect(buttonWithText("mongo.edit")).toBeDefined();
|
||||
|
||||
viewer.dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
||||
expect(documentBrowser.focusSearch()).toBe(true);
|
||||
expect(documentJsonEditor.openSearch).toHaveBeenCalledOnce();
|
||||
|
||||
viewer.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
await flushUi();
|
||||
expect(buttonWithText("grid.save")).toBeDefined();
|
||||
|
||||
Reference in new issue
Block a user