feat(mongodb): complete fields in filter and sort bars

This commit is contained in:
onenewcode
2026-09-18 20:05:26 +08:00
committed by GitHub
parent d2fd783592
commit 9788c20d3c
7 changed files with 1526 additions and 14 deletions
@@ -20,6 +20,7 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import type { DynamoDbIndexInfo, DynamoDbTableDescription } from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { getDataGridConditionSuggestionPosition, type DataGridConditionSuggestionPosition } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
import { documentViewerFontStyle } from "@/lib/document/documentViewerFontStyle";
import { ELASTICSEARCH_DEFAULT_MAX_RESULT_WINDOW, clampDocumentPage, resetElasticsearchDocumentTotals, resolveElasticsearchDocumentTotals } from "@/lib/document/elasticsearchDocumentTotals";
@@ -85,6 +86,18 @@ import {
serializeMongoDocumentId,
type MongoInputValue,
} from "@/lib/mongo/mongoDocumentValues";
import {
buildMongoCompletionItemsFromContext,
getMongoDocumentQueryCompletionContext,
inferMongoCompletionFields,
mongoCompletionNeedsFields,
plainMongoCompletionInsertion,
readMongoPropertyPrefix,
shouldAutoOpenMongoDocumentQueryCompletion,
type MongoCompletionField,
type MongoCompletionItem,
type MongoDocumentQueryKind,
} from "@/lib/mongo/mongoCompletion";
import { mongoDocumentsToQueryResult } from "@/lib/mongo/mongoShellCommand";
import type { GridNewRowMeta } from "@/lib/dataGrid/gridNewRowPlacement";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
@@ -95,6 +108,7 @@ import { matchesElasticsearchIndexPattern, subscribeElasticsearchIndexCleared, t
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import DocumentQueryCompletionMenu from "./DocumentQueryCompletionMenu.vue";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
@@ -964,6 +978,8 @@ function resizeDocumentQueryInput(el: HTMLTextAreaElement | undefined) {
function resizeDocumentQueryInputs() {
resizeDocumentQueryInput(filterInputRef.value);
resizeDocumentQueryInput(sortInputRef.value);
// A bar that just grew a line moved the menu's anchor with it.
repositionOpenDocumentQueryCompletions();
}
function formatFilterInput() {
@@ -990,6 +1006,330 @@ watch([filterInput, sortInput], () => {
void nextTick(resizeDocumentQueryInputs);
});
/* ---------------------------------------------------------------- *
* Filter / sort bar completion (MongoDB)
*
* The bars hold a bare query document, so they reuse the same field
* and operator tables as the query editor's `find({ … })` completion
* — a collection's field names are exactly what is too long to
* remember and retype here. Only MongoDB opts in: the other document
* stores put their own dialects in these inputs.
* ---------------------------------------------------------------- */
type DocumentQueryCompletionTarget = "filter" | "sort";
const DOCUMENT_QUERY_COMPLETION_MENU_LIMIT = 50;
const documentQueryCompletionTarget = ref<DocumentQueryCompletionTarget | null>(null);
const documentQueryCompletionItems = ref<MongoCompletionItem[]>([]);
const documentQueryCompletionIndex = ref(0);
const documentQueryCompletionPosition = ref<DataGridConditionSuggestionPosition>({ left: 0, top: 0, width: 0 });
const documentQueryCompletionListboxId = `document-query-completions-${uuid()}`;
const documentQueryCompletionEnabled = computed(() => documentStoreProvider.value.kind === "mongodb");
const documentQueryCompletionOpen = computed(() => documentQueryCompletionTarget.value !== null && documentQueryCompletionItems.value.length > 0);
const documentQueryCompletionActiveDescendant = computed(() => (documentQueryCompletionOpen.value ? `${documentQueryCompletionListboxId}-option-${documentQueryCompletionIndex.value}` : undefined));
// Guards the field lookup: a keystroke that lands while a previous refresh is
// still awaiting fields must win, and a dismiss must cancel both.
let documentQueryCompletionRequestId = 0;
function documentQueryCompletionKind(target: DocumentQueryCompletionTarget): MongoDocumentQueryKind {
return target === "filter" ? "filter" : "sortKeys";
}
function documentQueryCompletionInputEl(target: DocumentQueryCompletionTarget): HTMLTextAreaElement | undefined {
return target === "filter" ? filterInputRef.value : sortInputRef.value;
}
function documentQueryCompletionText(target: DocumentQueryCompletionTarget): string {
return target === "filter" ? filterInput.value : sortInput.value;
}
// Walking every loaded document is too much to redo on each keystroke — under
// infinite scroll `documents` holds every page fetched so far — so the page's
// fields are derived once per load and reused until the rows change.
const documentQueryCompletionLocalFields = computed<MongoCompletionField[]>(() => (documentQueryCompletionEnabled.value ? inferMongoCompletionFields(documents.value) : []));
/**
* Fields the collection is known to have: those visible in the loaded page,
* which carry the types the grid already inferred, plus the store's cached
* server-side sample, which also covers fields the current page happens not to
* contain.
*/
async function documentQueryCompletionFields(): Promise<MongoCompletionField[]> {
const byName = new Map(documentQueryCompletionLocalFields.value.map((field) => [field.name, field]));
let sampled: MongoCompletionField[] = [];
try {
sampled = await connectionStore.listMongoCompletionFields(props.connectionId, props.database, props.collection);
} catch {
sampled = [];
}
for (const field of sampled) if (!byName.has(field.name)) byName.set(field.name, field);
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function dismissDocumentQueryCompletions() {
documentQueryCompletionRequestId++;
documentQueryCompletionTarget.value = null;
documentQueryCompletionItems.value = [];
documentQueryCompletionIndex.value = 0;
}
async function refreshDocumentQueryCompletions(target: DocumentQueryCompletionTarget, options: { force?: boolean } = {}) {
if (!documentQueryCompletionEnabled.value) return;
const text = documentQueryCompletionText(target);
const cursor = documentQueryCompletionInputEl(target)?.selectionStart ?? text.length;
const kind = documentQueryCompletionKind(target);
// Without `force` (Ctrl/Cmd+Space, or a just-accepted item that opens a new
// position) the menu only appears for characters that start something.
if (!options.force && !shouldAutoOpenMongoDocumentQueryCompletion(text, cursor, kind)) {
dismissDocumentQueryCompletions();
return;
}
const context = getMongoDocumentQueryCompletionContext(text, cursor, kind);
if (context.mode === "none") {
dismissDocumentQueryCompletions();
return;
}
const requestId = ++documentQueryCompletionRequestId;
const fields = mongoCompletionNeedsFields(context.mode) ? await documentQueryCompletionFields() : [];
if (requestId !== documentQueryCompletionRequestId) return;
const items = buildMongoCompletionItemsFromContext(context, { fields }).slice(0, DOCUMENT_QUERY_COMPLETION_MENU_LIMIT);
if (items.length === 0) {
dismissDocumentQueryCompletions();
return;
}
documentQueryCompletionTarget.value = target;
documentQueryCompletionItems.value = items;
documentQueryCompletionIndex.value = 0;
updateDocumentQueryCompletionPosition(target);
}
/**
* Anchors the menu to its input in viewport coordinates, which is what a
* teleported menu needs: the grid's toolbar clips both axes, so the menu cannot
* live next to the input in the DOM.
*/
function updateDocumentQueryCompletionPosition(target: DocumentQueryCompletionTarget) {
const input = documentQueryCompletionInputEl(target);
if (!input) return;
documentQueryCompletionPosition.value = getDataGridConditionSuggestionPosition(input.getBoundingClientRect(), {
viewportWidth: window.innerWidth,
minWidth: 240,
maxWidth: 460,
});
}
// Teleported out of the input's box, the menu cannot follow it on its own: the
// toolbar scrolls horizontally and the window resizes without the input ever
// being touched.
function repositionOpenDocumentQueryCompletions() {
const target = documentQueryCompletionTarget.value;
if (target) updateDocumentQueryCompletionPosition(target);
}
watch(documentQueryCompletionOpen, (open) => {
if (open) {
window.addEventListener("scroll", repositionOpenDocumentQueryCompletions, true);
window.addEventListener("resize", repositionOpenDocumentQueryCompletions);
} else {
window.removeEventListener("scroll", repositionOpenDocumentQueryCompletions, true);
window.removeEventListener("resize", repositionOpenDocumentQueryCompletions);
}
});
function selectDocumentQueryCompletion(index: number) {
if (index < 0 || index >= documentQueryCompletionItems.value.length) return;
documentQueryCompletionIndex.value = index;
void nextTick(() => {
const listbox = document.getElementById(documentQueryCompletionListboxId);
const option = document.getElementById(`${documentQueryCompletionListboxId}-option-${index}`);
if (!listbox || !option) return;
const listboxRect = listbox.getBoundingClientRect();
const optionRect = option.getBoundingClientRect();
if (optionRect.top < listboxRect.top) listbox.scrollTop -= listboxRect.top - optionRect.top;
else if (optionRect.bottom > listboxRect.bottom) listbox.scrollTop += optionRect.bottom - listboxRect.bottom;
});
}
function moveDocumentQueryCompletionSelection(direction: 1 | -1): boolean {
if (!documentQueryCompletionOpen.value) return false;
const count = documentQueryCompletionItems.value.length;
const next = Math.min(Math.max(documentQueryCompletionIndex.value + direction, 0), count - 1);
if (next !== documentQueryCompletionIndex.value) selectDocumentQueryCompletion(next);
return true;
}
function documentQueryCompletionInsertion(index = documentQueryCompletionIndex.value) {
const target = documentQueryCompletionTarget.value;
const item = documentQueryCompletionItems.value[index];
if (!target || !item) return null;
const input = documentQueryCompletionInputEl(target);
const text = documentQueryCompletionText(target);
const cursor = input?.selectionStart ?? text.length;
const context = getMongoDocumentQueryCompletionContext(text, cursor, documentQueryCompletionKind(target));
if (context.mode === "none") return null;
// A quoted key completion writes both of its quotes, so the closing quote the
// input already holds has to go with the prefix it belongs to.
const to = Math.min((input?.selectionEnd ?? text.length) + (item.replaceClosingQuote ? 1 : 0), text.length);
const insertion = plainMongoCompletionInsertion(item.apply ?? item.label, text.slice(to));
return { target, text, from: context.from, to, insertion };
}
function acceptDocumentQueryCompletion(index = documentQueryCompletionIndex.value): boolean {
const completion = documentQueryCompletionInsertion(index);
if (!completion) return false;
const { target, text, from, to, insertion } = completion;
const next = `${text.slice(0, from)}${insertion.text}${text.slice(to)}`;
if (target === "filter") filterInput.value = next;
else sortInput.value = next;
dismissDocumentQueryCompletions();
void nextTick(() => {
const input = documentQueryCompletionInputEl(target);
if (!input) return;
input.focus();
input.setSelectionRange(from + insertion.selectionStart, from + insertion.selectionEnd);
// A field completion ends at `field: `, an operator at its value — both are
// fresh positions with their own suggestions, so open the menu again.
void refreshDocumentQueryCompletions(target, { force: true });
});
return true;
}
/** True when accepting would not change the text, so Enter should run the query instead. */
function documentQueryCompletionMatchesInput(): boolean {
const completion = documentQueryCompletionInsertion();
if (!completion) return false;
return completion.text.slice(completion.from, completion.to) === completion.insertion.text;
}
/**
* Opens the document around the first character typed into an empty bar, so
* `d` becomes `{d}` with the caret left between the braces.
*
* The bars hold a bare document and a field name only reads as a key once its
* braces exist, so typing straight into an empty bar used to land in a position
* that classifies as nothing and suggested nothing — the very case #9427
* reports. Nothing is lost by writing the braces: an unbraced bar never parses
* as a filter either, so that text was a dead query, not a shorter spelling of
* one.
*
* Only a lone character that could start a key qualifies. A paste arrives whole
* and usually brings its own braces, and a typed `{` is the user opening the
* document themselves — which already suggests.
*
* The edit has to be an insertion. Backspacing `ab` down to `a` leaves exactly
* the same one character and caret as typing `a` into an empty bar, and writing
* braces around text the user is in the middle of deleting would hand them two
* more characters to delete.
*/
function openDocumentQueryDocument(event: InputEvent, target: DocumentQueryCompletionTarget): boolean {
if (!documentQueryCompletionEnabled.value) return false;
if (!event.inputType?.startsWith("insert")) return false;
const text = documentQueryCompletionText(target);
const prefix = readMongoPropertyPrefix(text, text.length);
if ([...text].length !== 1 || !/^[$_"'\p{L}\p{N}]$/u.test(text) || prefix.from !== 0 || prefix.prefix !== text) return false;
if (documentQueryCompletionInputEl(target)?.selectionStart !== text.length) return false;
if (target === "filter") filterInput.value = `{${text}}`;
else sortInput.value = `{${text}}`;
// Rewriting the model moves the caret to the end, past the `}` we just added,
// where there is nothing to complete. Put it back inside before asking.
void nextTick(() => {
documentQueryCompletionInputEl(target)?.setSelectionRange(text.length + 1, text.length + 1);
void refreshDocumentQueryCompletions(target);
});
return true;
}
function onDocumentQueryInput(event: Event, target: DocumentQueryCompletionTarget) {
// `v-model` holds off on the model until the composition is confirmed, so a
// mid-composition refresh would suggest against the text as it was before the
// IME opened. Vue re-dispatches `input` once it commits, which is when the
// suggestions are worth computing.
if ((event as InputEvent).isComposing) return;
if (openDocumentQueryDocument(event as InputEvent, target)) return;
void refreshDocumentQueryCompletions(target);
}
/**
* Dismissing also cancels whatever refresh is in flight, which is the point:
* the target is only set once `documentQueryCompletionFields` has resolved, so
* a bar blurred during that first (uncached) backend round trip would otherwise
* open its menu afterwards, over a bar that no longer has focus. Nothing else
* would take it down — the menu is teleported to `body` and there is no
* outside-click handler.
*/
function onDocumentQueryBlur(target: DocumentQueryCompletionTarget) {
if (documentQueryCompletionTarget.value === null || documentQueryCompletionTarget.value === target) dismissDocumentQueryCompletions();
}
/**
* The suggestions describe the position the caret was in when they were built,
* so a caret moved without an edit leaves them describing somewhere else:
* accepting one then splices a stale item at a freshly computed offset and
* garbles the text. Moving the caret closes the menu instead.
*/
function onDocumentQueryCaretMove(target: DocumentQueryCompletionTarget) {
if (documentQueryCompletionTarget.value === target) dismissDocumentQueryCompletions();
}
function onDocumentQueryKeydown(event: KeyboardEvent, target: DocumentQueryCompletionTarget) {
if (event.isComposing) return;
if (documentQueryCompletionEnabled.value) {
if ((event.ctrlKey || event.metaKey) && event.code === "Space") {
event.preventDefault();
void refreshDocumentQueryCompletions(target, { force: true });
return;
}
if (event.key === "Escape" && documentQueryCompletionOpen.value) {
event.preventDefault();
dismissDocumentQueryCompletions();
return;
}
// These move the caret rather than the selection, so they leave the open
// suggestions describing a position the caret has left. The key still does
// its normal job — only the menu goes.
if (event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "Home" || event.key === "End") {
onDocumentQueryCaretMove(target);
return;
}
if (event.key === "ArrowDown" && moveDocumentQueryCompletionSelection(1)) {
event.preventDefault();
return;
}
if (event.key === "ArrowUp" && moveDocumentQueryCompletionSelection(-1)) {
event.preventDefault();
return;
}
if (event.key === "Tab" && !event.shiftKey && documentQueryCompletionOpen.value && acceptDocumentQueryCompletion()) {
event.preventDefault();
return;
}
// Enter takes the highlighted suggestion first; a second Enter runs the query.
if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && documentQueryCompletionOpen.value && !documentQueryCompletionMatchesInput() && acceptDocumentQueryCompletion()) {
event.preventDefault();
return;
}
}
if (event.key !== "Enter") return;
const plainEnter = !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
if (!plainEnter && !((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey)) return;
event.preventDefault();
dismissDocumentQueryCompletions();
applyFilter();
}
const documentQueryPreview = computed(() => {
let filter = "{}";
try {
@@ -2288,6 +2628,9 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
persistDocumentBrowserState({ includeData: true });
// The open/close watcher cannot run on unmount, so drop the menu's listeners here.
window.removeEventListener("scroll", repositionOpenDocumentQueryCompletions, true);
window.removeEventListener("resize", repositionOpenDocumentQueryCompletions);
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
unsubscribeElasticsearchIndexCleared?.();
unsubscribeElasticsearchIndexCleared = undefined;
@@ -2799,9 +3142,24 @@ defineExpose({ focusSearch });
rows="1"
class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
placeholder="{}"
@keydown.enter.exact.prevent="applyFilter"
@keydown.ctrl.enter.prevent="applyFilter"
@keydown.meta.enter.prevent="applyFilter"
:aria-autocomplete="documentQueryCompletionEnabled ? 'list' : undefined"
:aria-controls="documentQueryCompletionTarget === 'filter' ? documentQueryCompletionListboxId : undefined"
:aria-activedescendant="documentQueryCompletionTarget === 'filter' ? documentQueryCompletionActiveDescendant : undefined"
:aria-expanded="documentQueryCompletionEnabled ? documentQueryCompletionTarget === 'filter' : undefined"
@blur="onDocumentQueryBlur('filter')"
@click="onDocumentQueryCaretMove('filter')"
@input="onDocumentQueryInput($event, 'filter')"
@keydown="onDocumentQueryKeydown($event, 'filter')"
/>
<DocumentQueryCompletionMenu
v-if="documentQueryCompletionOpen && documentQueryCompletionTarget === 'filter'"
:items="documentQueryCompletionItems"
:selected-index="documentQueryCompletionIndex"
:listbox-id="documentQueryCompletionListboxId"
:label="documentStoreLabels.filterInputLabel"
:position="documentQueryCompletionPosition"
@select="selectDocumentQueryCompletion"
@accept="acceptDocumentQueryCompletion"
/>
<button v-if="filterInput.trim()" type="button" class="flex h-5 shrink-0 items-center text-muted-foreground hover:text-foreground" title="Format JSON" aria-label="Format JSON" @click="formatFilterInput">
<Braces class="w-3 h-3" />
@@ -2838,9 +3196,24 @@ defineExpose({ focusSearch });
rows="1"
class="document-query-input flex-1 min-w-0 text-xs bg-transparent outline-none placeholder:text-muted-foreground/60 font-mono"
placeholder="{}"
@keydown.enter.exact.prevent="applyFilter"
@keydown.ctrl.enter.prevent="applyFilter"
@keydown.meta.enter.prevent="applyFilter"
:aria-autocomplete="documentQueryCompletionEnabled ? 'list' : undefined"
:aria-controls="documentQueryCompletionTarget === 'sort' ? documentQueryCompletionListboxId : undefined"
:aria-activedescendant="documentQueryCompletionTarget === 'sort' ? documentQueryCompletionActiveDescendant : undefined"
:aria-expanded="documentQueryCompletionEnabled ? documentQueryCompletionTarget === 'sort' : undefined"
@blur="onDocumentQueryBlur('sort')"
@click="onDocumentQueryCaretMove('sort')"
@input="onDocumentQueryInput($event, 'sort')"
@keydown="onDocumentQueryKeydown($event, 'sort')"
/>
<DocumentQueryCompletionMenu
v-if="documentQueryCompletionOpen && documentQueryCompletionTarget === 'sort'"
:items="documentQueryCompletionItems"
:selected-index="documentQueryCompletionIndex"
:listbox-id="documentQueryCompletionListboxId"
:label="documentStoreLabels.sortInputLabel"
:position="documentQueryCompletionPosition"
@select="selectDocumentQueryCompletion"
@accept="acceptDocumentQueryCompletion"
/>
<button v-if="sortInput.trim()" type="button" class="flex h-5 shrink-0 items-center text-muted-foreground hover:text-foreground" title="Format JSON" aria-label="Format JSON" @click="formatSortInput">
<Braces class="w-3 h-3" />
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { CSSProperties } from "vue";
import { computed } from "vue";
import type { DataGridConditionSuggestionPosition } from "@/lib/dataGrid/dataGridConditionSuggestionPosition";
import type { MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
defineOptions({ name: "DocumentQueryCompletionMenu" });
const props = defineProps<{
items: MongoCompletionItem[];
selectedIndex: number;
listboxId: string;
label: string;
/** Viewport coordinates of the input this menu hangs under. */
position: DataGridConditionSuggestionPosition;
}>();
const emit = defineEmits<{
(e: "select", index: number): void;
(e: "accept", index: number): void;
}>();
/** Mirrors the query editor's completion icons: fields read as columns, operators as keywords. */
const TYPE_GLYPH: Record<MongoCompletionItem["type"], string> = {
column: "#",
function: "ƒ",
keyword: "$",
snippet: "{}",
table: "▤",
};
const style = computed<CSSProperties>(() => ({
left: `${props.position.left}px`,
top: `${props.position.top}px`,
width: `${props.position.width}px`,
}));
function glyph(item: MongoCompletionItem): string {
return TYPE_GLYPH[item.type] ?? "#";
}
function isSelected(index: number): boolean {
return props.selectedIndex === index;
}
</script>
<template>
<!--
The grid's toolbar clips both axes and is only 32px tall, so a dropdown
positioned inside it would never be visible. Teleporting to fixed viewport
coordinates is what the grid's own condition suggestions do.
-->
<Teleport to="body">
<!--
The container takes the same `mousedown.prevent` as its options: the list
scrolls, and a press on its scrollbar or padding would otherwise blur the
input and unmount the menu mid-drag, leaving everything below the fold
reachable by keyboard only.
-->
<div :id="listboxId" role="listbox" :aria-label="label" class="fixed z-[90] max-h-64 overflow-y-auto overflow-x-hidden rounded-md border bg-popover py-1 text-popover-foreground shadow-md" :style="style" @mousedown.prevent>
<button
v-for="(item, index) in items"
:id="`${listboxId}-option-${index}`"
:key="`${item.type}:${item.label}:${index}`"
type="button"
role="option"
:aria-selected="isSelected(index)"
:aria-description="item.info"
class="flex w-full items-center gap-2 px-2 py-1 text-left text-xs"
:class="isSelected(index) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'"
@pointerenter="emit('select', index)"
@mousedown.prevent
@click.stop="emit('accept', index)"
>
<span class="w-3 shrink-0 text-center font-mono text-[10px] text-muted-foreground">{{ glyph(item) }}</span>
<span class="min-w-0 flex-1 truncate font-mono">{{ item.label }}</span>
<span v-if="item.detail" class="max-w-[55%] shrink-0 truncate text-[11px] text-muted-foreground">{{ item.detail }}</span>
</button>
</div>
</Teleport>
</template>
@@ -280,6 +280,11 @@ async function flushUi() {
for (let index = 0; index < 4; index++) {
await Promise.resolve();
await nextTick();
// The mount chain (ensureConnected → load → render) spans more than a fixed
// number of microtask rounds once a real timer is in it, and DocumentBrowser
// renders from state those steps produce. Yielding to the macrotask queue
// lets each of them settle instead of leaving the component half-loaded.
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
@@ -0,0 +1,750 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const backend = vi.hoisted(() => ({
documentFindDocuments: vi.fn(),
documentCountDocuments: vi.fn(),
getColumns: vi.fn(),
cancelQuery: vi.fn(),
ensureConnected: vi.fn(),
listMongoCompletionFields: vi.fn(),
}));
const settings = vi.hoisted(() => ({
editorSettings: {
pageSize: 50,
mongoViewMode: "table" as "document" | "table",
columnWidthDensity: "standard" as "compact" | "standard" | "comfortable",
dataGridRenderMode: "canvas" as "canvas" | "dom",
tableFontFamily: "system-ui",
tableFontSize: 12,
numericColumnRightAlign: true,
confirmDangerousSqlExecution: true,
exportBatchSize: 2,
exportRowLimitEnabled: false,
exportRowLimit: 100_000,
},
updateEditorSettings: vi.fn(),
}));
vi.mock("vue-i18n", async (importOriginal) => ({
...(await importOriginal<typeof import("vue-i18n")>()),
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => ({
getColumns: backend.getColumns,
documentFindDocuments: backend.documentFindDocuments,
documentCountDocuments: backend.documentCountDocuments,
dynamodbDescribeTable: vi.fn(),
cancelQuery: backend.cancelQuery,
documentInsertDocument: vi.fn(),
documentUpdateDocument: vi.fn(),
documentDeleteDocument: vi.fn(),
documentSaveMeilisearchBatch: vi.fn(),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: backend.ensureConnected,
listMongoCompletionFields: backend.listMongoCompletionFields,
}),
}));
vi.mock("@/stores/settingsStore", () => ({
TABLE_FONT_SIZE_MIN: 8,
TABLE_FONT_SIZE_MAX: 16,
useSettingsStore: () => settings,
}));
vi.mock("@/components/grid/DataGrid.vue", () => ({
default: defineComponent({
name: "DataGridStub",
inheritAttrs: false,
props: { result: { type: Object, required: true } },
setup(_, { expose, slots }) {
expose({
visibleColumnCount: 0,
displayableColumnCount: 0,
hiddenColumnCount: 0,
orderedColumnLayoutOptions: [],
filteredColumnLayoutOptions: () => [],
toggleColumnVisibility: vi.fn(),
showAllColumns: vi.fn(),
invertColumnVisibility: vi.fn(),
hasCustomColumnOrder: false,
moveDisplayableColumn: vi.fn(),
resetColumnOrder: vi.fn(),
nullColumnsHidden: false,
canToggleAllNullColumns: false,
allNullColumnCount: 0,
toggleAllNullColumns: vi.fn(),
multiRowTranspose: false,
setMultiRowTranspose: vi.fn(),
});
return () =>
h("div", { "data-testid": "data-grid" }, [
slots["search-bar"]?.({
localFilterCount: 0,
hasLocalColumnFilters: false,
localFilterSummaries: [],
clearLocalFilter: vi.fn(),
}),
]);
},
}),
}));
vi.mock("@/components/redis/RedisJsonEditor.vue", async () => {
const { defineComponent: define, h: hh } = await import("vue");
return {
default: define({
props: { modelValue: { type: String, required: true } },
setup(props) {
return () => hh("div", {}, props.modelValue);
},
}),
};
});
vi.mock("@/components/ui/popover", async () => {
const { defineComponent: define, h: hh } = await import("vue");
const passthrough = (name: string) =>
define({
name,
setup(_, { slots }) {
return () => hh("div", slots.default?.());
},
});
return { Popover: passthrough("PopoverStub"), PopoverTrigger: passthrough("PopoverTriggerStub"), PopoverContent: passthrough("PopoverContentStub") };
});
vi.mock("@/components/ui/select", async () => {
const { defineComponent: define, h: hh } = await import("vue");
const passthrough = (name: string) =>
define({
name,
setup(_, { slots }) {
return () => hh("div", slots.default?.());
},
});
return { Select: passthrough("SelectStub"), SelectContent: passthrough("SelectContentStub"), SelectItem: passthrough("SelectItemStub"), SelectTrigger: passthrough("SelectTriggerStub"), SelectValue: passthrough("SelectValueStub") };
});
import DocumentBrowser from "@/components/document/DocumentBrowser.vue";
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
for (let index = 0; index < 4; index++) {
await Promise.resolve();
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
async function mountBrowser(databaseType: "mongodb" | "elasticsearch" = "mongodb") {
app = createApp(DocumentBrowser, {
connectionId: databaseType === "mongodb" ? "mongo-1" : "es-1",
database: "shop",
collection: "orders",
databaseType,
});
app.mount(root!);
await flushUi();
}
/** [filter, sort] — the two query bars rendered in the grid's search slot. */
function queryInputs(): HTMLTextAreaElement[] {
return [...root!.querySelectorAll<HTMLTextAreaElement>("textarea.document-query-input")];
}
/** `inputType` matters: the bars only open a document around an *inserted* character. */
async function typeInto(input: HTMLTextAreaElement, text: string, caret = text.length, inputType = "insertText") {
input.value = text;
input.setSelectionRange(caret, caret);
input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType }));
await flushUi();
}
/** Moves the caret the way a mouse click does — no edit, so no `input` event. */
async function clickCaretTo(input: HTMLTextAreaElement, offset: number) {
input.setSelectionRange(offset, offset);
input.dispatchEvent(new Event("click", { bubbles: true }));
await flushUi();
}
function pressKey(input: HTMLTextAreaElement, key: string, init: KeyboardEventInit = {}) {
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init });
input.dispatchEvent(event);
return event;
}
/**
* The menu is teleported out of the grid toolbar (which clips both axes), so it
* is only ever found on `document.body` — never under the mounted tree.
*/
function menu(): HTMLElement | null {
return document.body.querySelector<HTMLElement>('[role="listbox"]');
}
function menuOptions(): string[] {
return [...document.body.querySelectorAll<HTMLElement>('[role="listbox"] [role="option"] span:nth-child(2)')].map((option) => option.textContent ?? "");
}
function menuOpen(): boolean {
return menu() !== null;
}
function selectedOption(): string | null {
const option = document.body.querySelector<HTMLElement>('[role="option"][aria-selected="true"]');
return option?.querySelector("span:nth-child(2)")?.textContent ?? null;
}
beforeEach(() => {
vi.stubGlobal("localStorage", {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
});
for (const mock of Object.values(backend)) mock.mockReset();
backend.ensureConnected.mockResolvedValue(undefined);
backend.documentCountDocuments.mockResolvedValue(0);
backend.getColumns.mockResolvedValue([]);
backend.documentFindDocuments.mockResolvedValue({
documents: [
{ _id: "1", customerShippingAddress: "Beijing", createdAt: "2026-01-01" },
{ _id: "2", customerShippingAddress: "Shanghai", createdAt: "2026-01-02" },
],
raw_documents: [],
total: 2,
total_is_exact: true,
});
// The store's cached sample also knows a field the loaded page happens not to show.
backend.listMongoCompletionFields.mockResolvedValue([
{ name: "customerShippingAddress", type: "string" },
{ name: "discountCode", type: "string" },
{ name: "中文字段", type: "string" },
{ name: "客户 名", type: "string" },
]);
root = document.createElement("div");
document.body.appendChild(root);
});
afterEach(() => {
app?.unmount();
root?.remove();
app = null;
root = null;
vi.unstubAllGlobals();
});
describe("DocumentBrowser MongoDB query bar completion (issue #9427)", () => {
it("suggests the collection's fields as soon as the filter document is opened", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{");
expect(menuOpen()).toBe(true);
// Fields from the loaded page and from the store's sample, both offered.
expect(menuOptions()).toEqual(expect.arrayContaining(["_id", "createdAt", "customerShippingAddress", "discountCode"]));
});
it("escapes the grid toolbar's clipping by positioning itself on the body", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{");
// Anywhere inside the mounted tree the toolbar's `overflow-clip` would hide it.
expect(root!.querySelector('[role="listbox"]')).toBeNull();
expect(menu()!.parentElement).toBe(document.body);
expect(menu()!.className).toContain("fixed");
expect(menu()!.style.width).not.toBe("");
expect(menu()!.style.top).not.toBe("");
});
it("narrows the suggestions to the typed prefix", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{ customerS");
expect(menuOptions()).toEqual(["customerShippingAddress"]);
});
it("keeps completion open after an IME commits a Unicode first character", async () => {
await mountBrowser();
const [filter] = queryInputs();
filter!.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
filter!.value = "中";
filter!.setSelectionRange(1, 1);
filter!.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertCompositionText", isComposing: true }));
await flushUi();
expect(menuOpen()).toBe(false);
filter!.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
await typeInto(filter!, "中", 1, "insertCompositionText");
expect(filter!.value).toBe("{中}");
expect(menuOptions()).toEqual(["中文字段"]);
pressKey(filter!, "Tab");
await flushUi();
expect(filter!.value).toBe('{"中文字段": }');
await typeInto(filter!, '{"中文字段": 1 }');
pressKey(filter!, "Enter");
await flushUi();
expect(backend.documentFindDocuments.mock.calls.at(-1)?.[5]).toBe('{"中文字段":1}');
});
it("replaces a quoted field prefix containing spaces", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, '{ "客户 名');
expect(menuOptions()).toEqual(["客户 名"]);
pressKey(filter!, "Tab");
await flushUi();
expect(filter!.value).toBe('{ "客户 名": ');
});
it("completes the highlighted field on Tab and leaves the caret in value position", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{ customerS");
const event = pressKey(filter!, "Tab");
await flushUi();
expect(event.defaultPrevented).toBe(true);
expect(filter!.value).toBe("{ customerShippingAddress: ");
expect(filter!.selectionStart).toBe("{ customerShippingAddress: ".length);
});
it("moves through the suggestions with the arrow keys", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{ c");
const first = selectedOption();
pressKey(filter!, "ArrowDown");
await flushUi();
const second = selectedOption();
expect(second).not.toBe(first);
expect(menuOptions()).toContain(second);
pressKey(filter!, "ArrowUp");
await flushUi();
expect(selectedOption()).toBe(first);
});
it("takes the suggestion on the first Enter and runs the query on the next", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{ createdA");
const callsBefore = backend.documentFindDocuments.mock.calls.length;
pressKey(filter!, "Enter");
await flushUi();
expect(filter!.value).toBe("{ createdAt: ");
expect(backend.documentFindDocuments.mock.calls.length).toBe(callsBefore);
await typeInto(filter!, '{ createdAt: "2026-01-01" }');
pressKey(filter!, "Enter");
await flushUi();
expect(backend.documentFindDocuments.mock.calls.length).toBeGreaterThan(callsBefore);
expect(backend.documentFindDocuments.mock.calls.at(-1)?.[5]).toBe('{"createdAt":"2026-01-01"}');
});
it("closes the menu on Escape and lets the next Enter run the query unchanged", async () => {
await mountBrowser();
const [filter] = queryInputs();
// Caret inside the already-typed key, so the menu is open over a document
// that is otherwise complete and runnable.
await typeInto(filter!, '{ "createdAt": 1 }', '{ "createdAt'.length);
expect(menuOpen()).toBe(true);
const callsBefore = backend.documentFindDocuments.mock.calls.length;
pressKey(filter!, "Escape");
await flushUi();
expect(menuOpen()).toBe(false);
pressKey(filter!, "Enter");
await flushUi();
expect(filter!.value).toBe('{ "createdAt": 1 }');
expect(backend.documentFindDocuments.mock.calls.length).toBeGreaterThan(callsBefore);
expect(backend.documentFindDocuments.mock.calls.at(-1)?.[5]).toBe('{"createdAt":1}');
});
it("runs the query on Enter when the highlighted suggestion is what is already typed", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, '{ "createdAt": 1 }', '{ "createdAt'.length);
expect(selectedOption()).toBe("createdAt");
const callsBefore = backend.documentFindDocuments.mock.calls.length;
pressKey(filter!, "Enter");
await flushUi();
// Accepting would retype the same key, so Enter has nothing to take and the
// query runs instead — otherwise Enter could never submit this filter.
expect(filter!.value).toBe('{ "createdAt": 1 }');
expect(backend.documentFindDocuments.mock.calls.length).toBeGreaterThan(callsBefore);
});
it("replaces a quoted key in place without doubling its closing quote", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, '{ "customerS": 1 }', '{ "customerS'.length);
pressKey(filter!, "Tab");
await flushUi();
expect(filter!.value).toBe('{ "customerShippingAddress": 1 }');
});
it("suggests query operators inside a field constraint", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{ createdAt: { $g");
expect(menuOptions()).toEqual(expect.arrayContaining(["$gt", "$gte"]));
});
it("expands an operator snippet without leaving its editor placeholder behind", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{ createdAt: { $i");
while (selectedOption() !== "$in") {
const moved = pressKey(filter!, "ArrowDown");
await flushUi();
expect(moved.defaultPrevented, "ran out of suggestions before reaching $in").toBe(true);
}
pressKey(filter!, "Tab");
await flushUi();
expect(filter!.value).toBe("{ createdAt: { $in: []");
expect(filter!.selectionStart).toBe("{ createdAt: { $in: [".length);
});
it("completes field names in the sort bar without offering query operators", async () => {
await mountBrowser();
const [, sort] = queryInputs();
await typeInto(sort!, "{ c");
expect(menuOptions()).toEqual(expect.arrayContaining(["createdAt", "customerShippingAddress"]));
expect(menuOptions()).not.toEqual(expect.arrayContaining(["$and", "$or"]));
pressKey(sort!, "Tab");
await flushUi();
expect(sort!.value).toBe("{ createdAt: ");
});
it("keeps Shift+Enter for a newline and Ctrl+Enter for running the query", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, '{ "createdAt": 1 }');
const callsBefore = backend.documentFindDocuments.mock.calls.length;
const shiftEnter = pressKey(filter!, "Enter", { shiftKey: true });
await flushUi();
expect(shiftEnter.defaultPrevented).toBe(false);
expect(backend.documentFindDocuments.mock.calls.length).toBe(callsBefore);
const ctrlEnter = pressKey(filter!, "Enter", { ctrlKey: true });
await flushUi();
expect(ctrlEnter.defaultPrevented).toBe(true);
expect(backend.documentFindDocuments.mock.calls.length).toBeGreaterThan(callsBefore);
});
it("stays closed while an IME composition is still unconfirmed", async () => {
await mountBrowser();
const [filter] = queryInputs();
// A committed key, then dismissed — so any menu that appears next can only
// have come from the composition. Replacing that key with IME text leaves
// the caret inside the stale model text, which is where a refresh would
// wrongly find something to say.
await typeInto(filter!, "{ createdAt");
pressKey(filter!, "Escape");
await flushUi();
expect(menuOpen()).toBe(false);
// v-model withholds the model until the IME commits, so a refresh here
// would suggest against "{ createdAt" while the user is typing 收货地址.
filter!.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
filter!.value = "{ 收";
filter!.setSelectionRange(4, 4);
filter!.dispatchEvent(new InputEvent("input", { bubbles: true, isComposing: true }));
await flushUi();
expect(menuOpen()).toBe(false);
// Vue re-dispatches `input` when the composition commits, and that edit is
// worth suggesting against.
filter!.value = "{ c";
filter!.setSelectionRange(3, 3);
filter!.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
await flushUi();
expect(menuOpen()).toBe(true);
expect(menuOptions()).toEqual(expect.arrayContaining(["createdAt", "customerShippingAddress"]));
});
it("ignores an IME confirmation Enter", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, '{ "createdAt": 1 }');
const callsBefore = backend.documentFindDocuments.mock.calls.length;
pressKey(filter!, "Enter", { isComposing: true });
await flushUi();
expect(backend.documentFindDocuments.mock.calls.length).toBe(callsBefore);
});
it("says nothing once the filter document is complete", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, '{ createdAt: "2026-01-01" }');
expect(menuOpen()).toBe(false);
});
it("leaves the other document stores' query bars alone", async () => {
await mountBrowser("elasticsearch");
const [filter] = queryInputs();
await typeInto(filter!, "{ c");
expect(menuOpen()).toBe(false);
expect(backend.listMongoCompletionFields).not.toHaveBeenCalled();
});
});
describe("DocumentBrowser MongoDB query bar completion lifecycle", () => {
it("does not open a menu on a bar that lost focus while the fields were loading", async () => {
// The first keystroke in a collection waits on a real backend sample, and
// the target is only set once it resolves — so a blur before that has to
// cancel the refresh, not just close a menu that is not open yet.
let releaseFields!: (fields: Array<{ name: string; type: string }>) => void;
backend.listMongoCompletionFields.mockReturnValue(new Promise((resolve) => (releaseFields = resolve)));
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{");
expect(menuOpen()).toBe(false);
filter!.dispatchEvent(new Event("blur", { bubbles: true }));
await flushUi();
releaseFields([{ name: "discountCode", type: "string" }]);
await flushUi();
expect(menuOpen()).toBe(false);
});
it("closes rather than splices a stale suggestion when the caret is clicked elsewhere", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{ createdAt: 1, _i");
expect(menuOpen()).toBe(true);
// Into the middle of `createdAt`, where the open suggestions mean nothing.
await clickCaretTo(filter!, 3);
expect(menuOpen()).toBe(false);
pressKey(filter!, "Tab");
await flushUi();
expect(filter!.value).toBe("{ createdAt: 1, _i");
});
it("takes a suggestion clicked with the mouse", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{discountC");
const option = document.body.querySelector<HTMLElement>('[role="option"]');
// The press must not blur the input: blur dismisses, which would unmount
// the option before its click could land.
const mousedown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
option!.dispatchEvent(mousedown);
expect(mousedown.defaultPrevented).toBe(true);
option!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flushUi();
expect(filter!.value).toBe("{discountCode: ");
});
it("keeps the list usable when its scrollbar is pressed", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{");
const mousedown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
menu()!.dispatchEvent(mousedown);
await flushUi();
expect(mousedown.defaultPrevented).toBe(true);
expect(menuOpen()).toBe(true);
});
it("closes when an arrow key moves the caret, and lets the key through", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{ createdA");
expect(menuOpen()).toBe(true);
const event = pressKey(filter!, "ArrowLeft");
await flushUi();
expect(menuOpen()).toBe(false);
// The caret still has to move — only the menu was consumed.
expect(event.defaultPrevented).toBe(false);
});
});
/**
* Typing straight into an empty bar, without opening the document first — the
* way the issue's reporter did it, and the way anyone who has not noticed the
* `{}` placeholder will.
*/
describe("DocumentBrowser MongoDB query bars opened by typing (issue #9427)", () => {
it("opens the filter document around the first character and suggests from it", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "c");
expect(filter!.value).toBe("{c}");
// Between the braces, in key position — not after the `}` the rewrite added.
expect(filter!.selectionStart).toBe(2);
expect(menuOpen()).toBe(true);
expect(menuOptions()).toEqual(expect.arrayContaining(["createdAt", "customerShippingAddress"]));
});
it("opens the sort document the same way", async () => {
await mountBrowser();
const [, sort] = queryInputs();
await typeInto(sort!, "c");
expect(sort!.value).toBe("{c}");
expect(sort!.selectionStart).toBe(2);
expect(menuOpen()).toBe(true);
// The sort bar is a plain key map, so it offers no query operators.
expect(menuOptions()).toEqual(expect.arrayContaining(["createdAt", "customerShippingAddress"]));
expect(menuOptions().filter((option) => option.startsWith("$"))).toEqual([]);
});
it("completes a field typed without braces and leaves the caret in value position", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "d");
pressKey(filter!, "Tab");
await flushUi();
expect(filter!.value).toBe("{discountCode: }");
expect(filter!.selectionStart).toBe(15);
});
it("writes no braces of its own when the user opens the document", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{");
expect(filter!.value).toBe("{");
});
it("leaves a pasted document alone", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, '{ createdAt: "2026-01-01" }');
expect(filter!.value).toBe('{ createdAt: "2026-01-01" }');
});
it("leaves a backspace that happens to land on one character alone", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "ab", 2);
// Deleting down to `a` leaves the same character and caret as typing it.
await typeInto(filter!, "a", 1, "deleteContentBackward");
expect(filter!.value).toBe("a");
});
it("leaves a character typed mid-text alone", async () => {
await mountBrowser();
const [filter] = queryInputs();
// One character long, but the caret is before it: this is an edit, not a start.
await typeInto(filter!, "c", 0);
expect(filter!.value).toBe("c");
});
it("quotes a nested field path so the completed filter still parses", async () => {
backend.documentFindDocuments.mockResolvedValue({
documents: [{ _id: "1", customer: { name: "Ada", address: { city: "Beijing" } } }],
raw_documents: [],
total: 1,
total_is_exact: true,
});
backend.listMongoCompletionFields.mockResolvedValue([]);
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "{customer.n}", 11);
expect(menuOptions()).toEqual(["customer.name"]);
pressKey(filter!, "Tab");
await flushUi();
// `{ customer.name: 1 }` is a document in neither the shell nor JSON.
expect(filter!.value).toBe('{"customer.name": }');
expect(() => JSON.parse(filter!.value.replace(": }", ": 1}"))).not.toThrow();
});
it("still leaves a plain identifier key unquoted", async () => {
await mountBrowser();
const [filter] = queryInputs();
await typeInto(filter!, "d");
pressKey(filter!, "Tab");
await flushUi();
expect(filter!.value).toBe("{discountCode: }");
});
it("does not open documents in the other stores' bars", async () => {
await mountBrowser("elasticsearch");
const [filter] = queryInputs();
await typeInto(filter!, "c");
expect(filter!.value).toBe("c");
expect(menuOpen()).toBe(false);
});
});
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { buildMongoCompletionItemsFromContext, getMongoDocumentQueryCompletionContext, plainMongoCompletionInsertion, shouldAutoOpenMongoDocumentQueryCompletion, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
const FIELDS: MongoCompletionField[] = [
{ name: "_id", type: "objectId" },
{ name: "customerShippingAddress", type: "string" },
{ name: "customer.name", type: "string" },
{ name: "createdAt", type: "date" },
{ name: "中文字段", type: "string" },
{ name: "客户 名", type: "string" },
];
function contextAt(text: string, kind: "filter" | "sortKeys" = "filter", cursor = text.length) {
return getMongoDocumentQueryCompletionContext(text, cursor, kind);
}
function labelsAt(text: string, kind: "filter" | "sortKeys" = "filter", cursor = text.length) {
return buildMongoCompletionItemsFromContext(contextAt(text, kind, cursor), { fields: FIELDS }).map((item) => item.label);
}
describe("getMongoDocumentQueryCompletionContext", () => {
it("completes fields at the root of a filter document", () => {
expect(contextAt("{ ")).toMatchObject({ mode: "filterField", prefix: "", from: 2 });
expect(labelsAt("{ ")).toEqual(expect.arrayContaining(["_id", "customerShippingAddress", "createdAt"]));
});
it("narrows fields by the typed prefix and reports where to splice it", () => {
expect(contextAt("{ customerS")).toMatchObject({ mode: "filterField", prefix: "customerS", from: 2 });
expect(labelsAt("{ customerS")).toEqual(["customerShippingAddress"]);
});
it("keeps Unicode and quoted spaces in the field prefix and splice range", () => {
expect(contextAt("{ 中")).toMatchObject({ mode: "filterField", prefix: "中", from: 2 });
expect(labelsAt("{ 中")).toEqual(["中文字段"]);
expect(contextAt('{ "客户 名')).toMatchObject({ mode: "filterField", prefix: '"客户 名', from: 2 });
expect(labelsAt('{ "客户 名')).toEqual(["客户 名"]);
});
it("matches fields case-insensitively inside a quoted key", () => {
expect(contextAt('{ "created')).toMatchObject({ mode: "filterField", prefix: '"created', from: 2 });
const items = buildMongoCompletionItemsFromContext(contextAt('{ "created'), { fields: FIELDS });
expect(items.map((item) => item.apply)).toContain('"createdAt": ');
});
it("consumes an existing closing quote so a key is not double-quoted", () => {
expect(contextAt('{ "created"', "filter", 10)).toMatchObject({ prefix: '"created', replaceClosingQuote: '"' });
});
it("offers whole-filter operators once $ is typed", () => {
expect(labelsAt("{ $")).toEqual(expect.arrayContaining(["$and", "$or"]));
});
it("offers query operators inside a field constraint", () => {
expect(contextAt("{ createdAt: { $g")).toMatchObject({ mode: "queryOperator", prefix: "$g" });
expect(labelsAt("{ createdAt: { $g")).toEqual(expect.arrayContaining(["$gt", "$gte"]));
});
it("offers fields again inside an $and branch and after a comma", () => {
expect(contextAt("{ $and: [{ ")).toMatchObject({ mode: "filterField" });
expect(contextAt("{ _id: 1, ")).toMatchObject({ mode: "filterField" });
});
it("offers value constructors in value position", () => {
expect(contextAt("{ _id: ")).toMatchObject({ mode: "value" });
expect(labelsAt("{ _id: ")).toEqual(expect.arrayContaining(["ObjectId"]));
});
it("says nothing inside a string value", () => {
expect(contextAt('{ customerShippingAddress: "Beij')).toMatchObject({ mode: "none" });
});
it("says nothing outside the document", () => {
expect(contextAt("")).toMatchObject({ mode: "none" });
expect(contextAt("{ _id: 1 }")).toMatchObject({ mode: "none" });
expect(contextAt("{ _id: 1 } ")).toMatchObject({ mode: "none" });
expect(contextAt("}")).toMatchObject({ mode: "none" });
});
it("never offers shell snippets a filter bar cannot hold", () => {
// `db.` is a plain token here, not the start of a collection chain.
expect(labelsAt("{ db.")).not.toEqual(expect.arrayContaining(["db.getCollection", "find"]));
});
it("completes sort keys without value or operator noise", () => {
expect(contextAt("{ created", "sortKeys")).toMatchObject({ mode: "field", prefix: "created" });
expect(labelsAt("{ created", "sortKeys")).toEqual(["createdAt"]);
expect(contextAt("{ createdAt: ", "sortKeys")).toMatchObject({ mode: "none" });
expect(contextAt("{ createdAt: -1, ", "sortKeys")).toMatchObject({ mode: "field" });
});
});
describe("plainMongoCompletionInsertion", () => {
it("leaves a plain key completion untouched, caret at the end", () => {
expect(plainMongoCompletionInsertion("createdAt: ")).toEqual({ text: "createdAt: ", selectionStart: 11, selectionEnd: 11 });
});
it("drops an empty snippet marker and leaves the caret in its place", () => {
expect(plainMongoCompletionInsertion("$gt: ${}")).toEqual({ text: "$gt: ", selectionStart: 5, selectionEnd: 5 });
expect(plainMongoCompletionInsertion("$in: [${}]")).toEqual({ text: "$in: []", selectionStart: 6, selectionEnd: 6 });
});
it("selects a named placeholder so its default can be typed over", () => {
expect(plainMongoCompletionInsertion('$regex: "${pattern}"')).toEqual({ text: '$regex: "pattern"', selectionStart: 9, selectionEnd: 16 });
});
it("drops the key separator when the text it lands in already has one", () => {
expect(plainMongoCompletionInsertion('"createdAt": ', ": 1 }")).toEqual({ text: '"createdAt"', selectionStart: 11, selectionEnd: 11 });
expect(plainMongoCompletionInsertion("createdAt: ", " : 1 }")).toEqual({ text: "createdAt", selectionStart: 9, selectionEnd: 9 });
// Nothing to fold into: the separator is still the completion's job.
expect(plainMongoCompletionInsertion("createdAt: ", " }").text).toBe("createdAt: ");
expect(plainMongoCompletionInsertion("createdAt: ", "").text).toBe("createdAt: ");
// An operator snippet ends at its value, so its own colon must survive.
expect(plainMongoCompletionInsertion("$gt: ${}", ": 1 }").text).toBe("$gt: ");
});
it("expands every marker but only selects the first", () => {
expect(plainMongoCompletionInsertion("$mod: [${divisor}, ${remainder}]")).toEqual({ text: "$mod: [divisor, remainder]", selectionStart: 7, selectionEnd: 14 });
expect(plainMongoCompletionInsertion('$split: [${}, "${,}"]')).toEqual({ text: '$split: [, ","]', selectionStart: 9, selectionEnd: 9 });
});
it("renders every operator table entry without leaving a marker behind", () => {
const applies = buildMongoCompletionItemsFromContext(contextAt("{ createdAt: { "), { fields: FIELDS })
.concat(buildMongoCompletionItemsFromContext(contextAt("{ $"), { fields: FIELDS }))
.concat(buildMongoCompletionItemsFromContext(contextAt("{ createdAt: "), { fields: FIELDS }))
.map((item) => item.apply ?? item.label);
expect(applies.length).toBeGreaterThan(20);
for (const apply of applies) {
const insertion = plainMongoCompletionInsertion(apply);
expect(insertion.text, apply).not.toContain("${");
expect(insertion.selectionEnd, apply).toBeLessThanOrEqual(insertion.text.length);
}
});
});
describe("shouldAutoOpenMongoDocumentQueryCompletion", () => {
it("opens on the characters that start a key, an operator, or a value", () => {
for (const text of ["{", "{ ", "{ cre", '{ "cre', "{ 中", '{ "客户 名', "{ $", "{ _id: 1,", "{ createdAt: {", "{ createdAt: "]) {
expect(shouldAutoOpenMongoDocumentQueryCompletion(text, text.length, "filter"), text).toBe(true);
}
});
it("stays closed where there is nothing to say", () => {
for (const text of ["", "{ _id: 1 }", "{ _id: 1 } ", '{ name: "x']) {
expect(shouldAutoOpenMongoDocumentQueryCompletion(text, text.length, "filter"), text).toBe(false);
}
});
});
+157 -7
View File
@@ -247,7 +247,7 @@ export function getMongoCompletionContext(text: string, cursor: number): MongoCo
const safeCursor = Math.max(0, Math.min(cursor, text.length));
const beforeCursor = text.slice(0, safeCursor);
const collection = extractActiveCollection(text, safeCursor);
const { prefix, from } = readPropertyPrefix(text, safeCursor);
const { prefix, from } = readMongoPropertyPrefix(text, safeCursor);
const replaceClosingQuote = closingQuoteAtCursor(prefix, text, safeCursor);
const at = (mode: MongoCompletionMode, stage?: string): MongoCompletionContext => ({ mode, prefix, from, replaceClosingQuote, collection, stage });
@@ -399,6 +399,106 @@ export function shouldAutoOpenMongoCompletion(text: string, cursor: number): boo
return false;
}
/* ------------------------------------------------------------------ *
* Standalone document inputs
* ------------------------------------------------------------------ */
/**
* Which bare document an input holds, and therefore how its keys read: the
* document browser's filter bar is a query document, its sort bar a key map.
*/
export type MongoDocumentQueryKind = "filter" | "sortKeys";
/**
* Completion for an input holding one bare document rather than a shell
* command — the document browser's filter and sort bars.
*
* There is no `db.coll.find(…)` here to locate the cursor within, so the text is
* scanned as if it *were* that call's argument list: `{ na` lands in the query
* document's root object exactly as it would inside `find({ na`. Everything the
* shell path reaches by walking the `db.…` chain (collection names, helper
* methods, cursor methods) is unreachable by construction, which is what we
* want — none of it could be pasted into a filter bar.
*/
export function getMongoDocumentQueryCompletionContext(text: string, cursor: number, kind: MongoDocumentQueryKind): MongoCompletionContext {
const safeCursor = Math.max(0, Math.min(cursor, text.length));
const nothing: MongoCompletionContext = { mode: "none", prefix: "", from: safeCursor };
if (isInsideMongoComment(text, safeCursor)) return nothing;
// Scanning from 0 treats the input as the argument list itself. A `}` that
// closes more than the text opened returns null — the cursor has left the
// document, e.g. it trails a finished `{ a: 1 }`.
const scan = scanMongoCallArguments(text, 0, safeCursor);
if (!scan) return nothing;
const mode = kind === "filter" ? classifyFilter(scan, 0) : classifyKeyMap(scan, 0);
if (mode === "none") return nothing;
const { prefix, from } = readMongoPropertyPrefix(text, safeCursor);
return { mode, prefix, from, replaceClosingQuote: closingQuoteAtCursor(prefix, text, safeCursor) };
}
/** Text to splice into a plain input for a chosen completion, and where to leave the selection. */
export interface MongoPlainCompletionInsertion {
text: string;
/** Start of the selection within `text`, relative to its first character. */
selectionStart: number;
/** End of that selection; equal to the start when the placeholder was empty. */
selectionEnd: number;
}
/**
* Renders an item's `apply` string for a plain `<input>`/`<textarea>`.
*
* Most operator completions are authored as CodeMirror snippets (`$in: [${}]`,
* `$regex: "${pattern}"`), and CodeMirror expands the `${…}` markers itself.
* Nothing does that outside the editor, so strip the markers and hand back the
* span of the first placeholder: an empty one becomes the caret position, a
* named one is selected so its default can be typed straight over.
*
* Key completions carry their own `: ` because they are only offered in key
* position. Pass `followingText` — whatever the input holds after the replaced
* range — so re-picking the key of an existing `{ "name": 1 }` entry replaces
* the key instead of leaving `{ "other": : 1 }` behind.
*/
export function plainMongoCompletionInsertion(apply: string, followingText = ""): MongoPlainCompletionInsertion {
let text = "";
let selection: { start: number; end: number } | null = null;
let rest = apply;
for (;;) {
const match = /\$\{([^{}]*)\}/.exec(rest);
if (!match) break;
const placeholder = match[1] ?? "";
text += rest.slice(0, match.index);
if (!selection) selection = { start: text.length, end: text.length + placeholder.length };
text += placeholder;
rest = rest.slice(match.index + match[0].length);
}
text += rest;
// Only a plain key completion may fold its separator away. A snippet's colon
// introduces the placeholder that follows it (`$gt: ${}`), so it is never the
// same colon as one already in the text.
if (!selection && text.endsWith(": ") && /^\s*:/.test(followingText)) text = text.slice(0, -2);
return { text, selectionStart: Math.min(selection?.start ?? text.length, text.length), selectionEnd: Math.min(selection?.end ?? text.length, text.length) };
}
/**
* Whether typing the character before the cursor should pop the menu open on its
* own. Mirrors `shouldAutoOpenMongoCompletion` minus the `db.` chain, and
* confirms the position has something to say so a space or a closing brace does
* not reopen an empty menu.
*/
export function shouldAutoOpenMongoDocumentQueryCompletion(text: string, cursor: number, kind: MongoDocumentQueryKind): boolean {
const previousChar = text[cursor - 1];
if (!previousChar) return false;
const context = getMongoDocumentQueryCompletionContext(text, cursor, kind);
if (context.mode === "none") return false;
return context.prefix.length > 0 || /[{[,:]\s*$/.test(text.slice(0, cursor));
}
/**
* How far the editor may keep re-filtering a result before it has to ask us
* again. In the `db.…` chain every `.` moves the cursor to a different item
@@ -910,11 +1010,47 @@ function finalizeQuotedMongoCompletionItems(context: MongoCompletionContext, ite
* Text helpers
* ------------------------------------------------------------------ */
function readPropertyPrefix(text: string, cursor: number): { prefix: string; from: number } {
let from = cursor;
while (from > 0 && /[\w_$.-]/.test(text[from - 1] ?? "")) from--;
if (text[from - 1] === '"' || text[from - 1] === "'") from--;
return { prefix: text.slice(from, cursor), from };
const MONGO_PROPERTY_PREFIX_CHARACTER = /[$.\p{ID_Continue}-]/u;
export function readMongoPropertyPrefix(text: string, cursor: number): { prefix: string; from: number } {
const safeCursor = Math.max(0, Math.min(cursor, text.length));
const quoteStart = findOpenMongoQuoteStart(text, safeCursor);
if (quoteStart !== null) return { prefix: text.slice(quoteStart, safeCursor), from: quoteStart };
let from = safeCursor;
while (from > 0) {
let candidateFrom = from - 1;
const trailingUnit = text.charCodeAt(candidateFrom);
if (trailingUnit >= 0xdc00 && trailingUnit <= 0xdfff && candidateFrom > 0) {
const leadingUnit = text.charCodeAt(candidateFrom - 1);
if (leadingUnit >= 0xd800 && leadingUnit <= 0xdbff) candidateFrom--;
}
const candidate = text.slice(candidateFrom, from);
if (!MONGO_PROPERTY_PREFIX_CHARACTER.test(candidate)) break;
from = candidateFrom;
}
return { prefix: text.slice(from, safeCursor), from };
}
function findOpenMongoQuoteStart(text: string, cursor: number): number | null {
for (let index = 0; index < cursor; index++) {
const char = text[index];
if ((char === "/" && (text[index + 1] === "/" || text[index + 1] === "*")) || (char === "-" && text[index + 1] === "-")) {
const skipped = skipMongoStringOrComment(text, index, cursor);
if (skipped >= cursor) return null;
index = skipped - 1;
continue;
}
if (char !== '"' && char !== "'") continue;
const quoteStart = index;
for (index++; index < cursor; index++) {
if (text[index] === "\\") index++;
else if (text[index] === char) break;
}
if (index >= cursor) return quoteStart;
}
return null;
}
function closingQuoteAtCursor(prefix: string, text: string, cursor: number): '"' | "'" | undefined {
@@ -1116,9 +1252,23 @@ function describeMongoValueType(value: unknown): string {
return mongoExtendedJsonValueType(value) ?? (typeof value === "object" ? "object" : typeof value);
}
/**
* Keys that may be written bare. Anything else has to carry quotes: a nested
* path (`customer.name`) is the common case, but a hyphen or a leading digit
* does it too. `inferMongoCompletionFields` emits a path per nesting level, so
* a collection of nested documents offers mostly non-bare keys.
*/
const BARE_MONGO_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/**
* Renders a field name in key position. An unquoted prefix keeps the key bare
* only while the name allows it — `{ customer.name: 1 }` parses neither as a
* shell document nor as the JSON the document browser's query bars are read
* as, so a path is quoted even though the user typed no quote.
*/
function quoteMongoFieldName(field: string, prefix: string): string {
if (prefix.startsWith('"')) return `"${escapeDoubleQuoted(field)}"`;
if (prefix.startsWith("'")) return `'${escapeSingleQuoted(field)}'`;
if (prefix.startsWith('"') || !BARE_MONGO_KEY.test(field)) return `"${escapeDoubleQuoted(field)}"`;
return field;
}
+7 -1
View File
@@ -95,6 +95,12 @@ test("document pagination commits page index with fetched rows", () => {
test("document query inputs apply on Enter and reserve Shift+Enter for newlines", () => {
const source = documentBrowserSource();
assert.equal(source.match(/@keydown\.enter\.exact\.prevent="applyFilter"/g)?.length, 2);
// Both bars route every key through one handler, because Enter has to pick a
// pending completion before it may run the query.
assert.equal(source.match(/@keydown="onDocumentQueryKeydown\(\$event, '(?:filter|sort)'\)"/g)?.length, 2);
assert.match(source, /const plainEnter = !event\.ctrlKey && !event\.metaKey && !event\.altKey && !event\.shiftKey;/);
// Plain Enter and Ctrl/Cmd+Enter apply; Shift+Enter and Alt+Enter fall
// through to the textarea so they still insert a newline.
assert.match(source, /if \(!plainEnter && !\(\(event\.ctrlKey \|\| event\.metaKey\) && !event\.altKey && !event\.shiftKey\)\) return;/);
assert.doesNotMatch(source, /@keydown\.shift\.enter\.prevent="applyFilter"/);
});