mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(mongodb): complete fields in filter and sort bars
This commit is contained in:
@@ -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>
|
||||
+5
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+750
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user