feat(mongo): refine document browsing and structured filter experience

This commit is contained in:
zipg 2026-08-08 11:36:57 +08:00 committed by GitHub
parent 0107d5fafb
commit e49d86b101
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1031 additions and 171 deletions

View File

@ -2,7 +2,7 @@
import { computed, ref, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter } from "@lucide/vue";
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@ -13,6 +13,9 @@ import ErrorBanner from "@/components/ui/ErrorBanner.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue";
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { Switch } from "@/components/ui/switch";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
@ -34,6 +37,7 @@ import {
searchDocumentFieldPathTree,
documentFilterModeNeedsValue,
documentFilterModeOptions,
documentFilterValueTypeOptions,
documentStoreProviderFor,
elasticsearchBoolClauseOptions,
elasticsearchFieldPathTreeFromFieldNames,
@ -45,6 +49,7 @@ import {
type DocumentFieldPathNode,
type DocumentFilterMode,
type DocumentFilterRule,
type DocumentFilterValueType,
type DocumentStoreKind,
type ElasticsearchBoolClause,
type ElasticsearchQueryType,
@ -63,12 +68,12 @@ import {
import { applyDocumentStoreIdentityPlan, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentDisplayValue, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { buildMongoInsertDocument, buildMongoUpdateDocument, formatMongoShellLiteral, mongoDocumentDisplayValue, mongoDocumentGridColumnTypes, mongoDocumentIdForGrid, parseMongoDocumentInputValue, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { findDocumentTextMatches, renderDocumentJsonHtml } from "@/lib/document/documentJsonSearch";
import { documentDataGridColumnLayoutScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibilityToLayout } from "@/lib/document/documentGridColumnVisibilityStorage";
import { useSettingsStore } from "@/stores/settingsStore";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore } from "@/stores/settingsStore";
import JsonEditNode from "./JsonEditNode.vue";
import type { EditNode } from "@/types/editor";
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
@ -95,6 +100,7 @@ const documents = ref<JsonRecord[]>([]);
const copyDocuments = ref<JsonRecord[]>([]);
const mongoCopyDocumentsAvailable = ref(false);
const lastGridColumns = ref<string[]>([]);
const lastGridColumnTypes = ref<string[]>([]);
const total = ref<number | undefined>(undefined);
const totalIsExact = ref(true);
const paginationTotal = ref<number | undefined>(undefined);
@ -113,6 +119,10 @@ const isSavingDocument = ref(false);
const error = ref("");
const editFields = ref<EditNode[]>([]);
const showDeleteConfirm = ref(false);
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
const numericColumnRightAlign = computed(() => settingsStore.editorSettings.numericColumnRightAlign ?? true);
const viewMode = computed<ViewMode>({
get: () => settingsStore.editorSettings.mongoViewMode,
set: (value) => settingsStore.updateEditorSettings({ mongoViewMode: value }),
@ -136,6 +146,30 @@ function openDataGridExtractorConfiguration() {
viewOptionsOpen.value = false;
void nextTick(() => dataGridRef.value?.openExtractorConfiguration());
}
function setColumnWidthDensity(value: "compact" | "standard" | "comfortable") {
settingsStore.updateEditorSettings({ columnWidthDensity: value });
}
function setDataGridRenderMode(value: "canvas" | "dom") {
settingsStore.updateEditorSettings({ dataGridRenderMode: value });
}
function setTableFontSize(value: number) {
settingsStore.updateEditorSettings({ tableFontSize: value });
}
function decreaseTableFontSize() {
setTableFontSize(tableFontSize.value - 1);
}
function increaseTableFontSize() {
setTableFontSize(tableFontSize.value + 1);
}
function setNumericColumnRightAlign(value: boolean) {
settingsStore.updateEditorSettings({ numericColumnRightAlign: value });
}
const tableSearchSplitContainerRef = ref<HTMLDivElement>();
const tableFindPaneWidth = ref<number | null>(null);
const isResizingTableSearchSplit = ref(false);
@ -223,11 +257,21 @@ const appliedDocumentFilter = ref<Record<string, unknown> | null>(null);
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
const pendingDelete = ref<PendingDelete | null>(null);
const documentFilterComposingEditors = new Set<string>();
const documentFilterCompositionEndedAt = new Map<string, number>();
const DOCUMENT_FILTER_IME_COMPOSITION_END_GRACE_MS = 120;
const selectedDoc = computed(() => {
if (selectedIdx.value === null) return null;
return documents.value[selectedIdx.value] ?? null;
});
const selectedDocumentIdLabel = computed(() => {
if (isNew.value) return "New";
const id = selectedDoc.value?._id;
if (id === undefined || id === null) return "";
return typeof id === "object" ? stringifyDocumentStoreValue(id, documentStoreProvider.value.kind) : String(id);
});
const selectedDocumentIdWidth = computed(() => `${Math.min(Math.max(Array.from(selectedDocumentIdLabel.value).length + 2, 5), 52)}ch`);
const documentSearchText = computed(() => editJson.value);
const documentSearchMatches = computed(() => findDocumentTextMatches(documentSearchText.value, documentSearchQuery.value));
const documentSearchActiveIndex = computed(() => {
@ -266,6 +310,7 @@ const gridResult = computed<QueryResult>(() => {
if (!docs.length) {
return {
columns: lastGridColumns.value,
column_types: lastGridColumnTypes.value,
rows: [],
affected_rows: 0,
execution_time_ms: 0,
@ -281,6 +326,7 @@ const gridResult = computed<QueryResult>(() => {
}
}
const columns = [...keySet];
const columnTypes = documentStoreProvider.value.kind === "mongodb" ? mongoDocumentGridColumnTypes(docs, columns) : undefined;
const rows = docs.map((doc) =>
columns.map((col) => {
@ -293,7 +339,7 @@ const gridResult = computed<QueryResult>(() => {
}),
);
return { columns, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
return { columns, column_types: columnTypes, rows, mongo_documents: docs, mongo_copy_documents: copyDocuments.value, affected_rows: 0, execution_time_ms: 0, truncated: false };
});
const expandedDocumentFilterFieldPaths = ref<Set<string>>(new Set());
const elasticsearchFieldTypes = computed(() => new Map(elasticsearchMappingFields.value.map((field) => [field.name, field.data_type])));
@ -358,9 +404,52 @@ function ensureDocumentFilterRule() {
}
}
function addDocumentFilterRule() {
function appendDocumentFilterRule(openFieldSelect: boolean) {
ensureDocumentFilterRule();
documentFilterRules.value = [...documentFilterRules.value, createDocumentFilterRule()];
const rule = createDocumentFilterRule();
documentFilterRules.value = [...documentFilterRules.value, rule];
if (openFieldSelect) setDocumentFilterFieldPopoverOpen(rule.id, true);
}
function addDocumentFilterRule() {
appendDocumentFilterRule(false);
}
function addDocumentFilterRuleFromKeyboard() {
appendDocumentFilterRule(true);
}
function startDocumentFilterImeComposition(editorKey: string) {
documentFilterComposingEditors.add(editorKey);
documentFilterCompositionEndedAt.delete(editorKey);
}
function endDocumentFilterImeComposition(editorKey: string) {
documentFilterComposingEditors.delete(editorKey);
documentFilterCompositionEndedAt.set(editorKey, Date.now());
}
function isDocumentFilterImeCompositionKey(event: KeyboardEvent, editorKey: string) {
const endedAt = documentFilterCompositionEndedAt.get(editorKey);
const justEnded = event.key === "Enter" && endedAt !== undefined && Date.now() - endedAt <= DOCUMENT_FILTER_IME_COMPOSITION_END_GRACE_MS;
if (justEnded || (endedAt !== undefined && event.key !== "Process")) documentFilterCompositionEndedAt.delete(editorKey);
return event.isComposing || event.key === "Process" || event.keyCode === 229 || documentFilterComposingEditors.has(editorKey) || justEnded;
}
function handleDocumentFilterValueKeydown(event: KeyboardEvent, ruleId: string) {
const editorKey = `value:${ruleId}`;
if (isDocumentFilterImeCompositionKey(event, editorKey)) {
event.stopPropagation();
return;
}
if (event.key !== "Enter") return;
event.preventDefault();
if (!event.shiftKey) {
void applyDocumentStructuredFilters();
return;
}
event.stopPropagation();
if (!event.repeat) addDocumentFilterRuleFromKeyboard();
}
function visibleDocumentFilterFieldRows(nodes: readonly DocumentFieldPathNode[], depth = 0): DocumentFilterFieldTreeRow[] {
@ -445,8 +534,9 @@ function updateDocumentFilterRule(ruleId: string, patch: Partial<DocumentFilterR
next.elasticsearchQueryType = queryTypes[0];
}
if (!elasticsearchQueryTypeNeedsValue(next.elasticsearchQueryType)) next.rawValue = "";
} else if (!documentFilterModeNeedsValue(next.mode)) {
next.rawValue = "";
} else {
if (patch.fieldName !== undefined && patch.fieldName !== rule.fieldName) next.valueType = "auto";
if (!documentFilterModeNeedsValue(next.mode)) next.rawValue = "";
}
return next;
});
@ -535,15 +625,22 @@ async function applyDocumentStructuredFilters() {
applyFilter();
return;
}
const items = documentFilterRules.value
.map((rule) => ({
rule,
condition: buildDocumentFilterCondition(rule, {
kind: documentStoreProvider.value.kind,
sampleValue: documentFilterFieldByPath.value.get(rule.fieldName)?.sampleValue,
}),
}))
.filter((item): item is { rule: DocumentFilterRule; condition: Record<string, unknown> } => !!item.condition);
let items: Array<{ rule: DocumentFilterRule; condition: Record<string, unknown> }>;
try {
items = documentFilterRules.value
.map((rule) => ({
rule,
condition: buildDocumentFilterCondition(rule, {
kind: documentStoreProvider.value.kind,
sampleValue: documentFilterFieldByPath.value.get(rule.fieldName)?.sampleValue,
}),
}))
.filter((item): item is { rule: DocumentFilterRule; condition: Record<string, unknown> } => !!item.condition);
} catch (e) {
error.value = e instanceof Error ? e.message : String(e);
return;
}
error.value = "";
const structured = combineDocumentFilterConditions(
items.map((item) => item.condition),
items.map((item) => item.rule),
@ -934,6 +1031,7 @@ async function load(options: { page?: number } = {}) {
}
}
lastGridColumns.value = [...keySet];
lastGridColumnTypes.value = storeKind === "mongodb" ? mongoDocumentGridColumnTypes(nextDocuments, lastGridColumns.value) : [];
}
if (storeKind === "elasticsearch") {
applyElasticsearchSearchTotal(result.total, result.total_is_exact !== false, filter);
@ -1421,6 +1519,10 @@ async function applyDeleteDoc(idx: number) {
}
function requestDeleteDoc(idx: number) {
if (!settingsStore.editorSettings.confirmDangerousSqlExecution) {
void applyDeleteDoc(idx);
return;
}
pendingDelete.value = { kind: "document", index: idx };
showDeleteConfirm.value = true;
}
@ -1461,6 +1563,16 @@ function highlightedJson(json: string): string {
return renderDocumentJsonHtml(json, documentSearchOpen.value ? documentSearchQuery.value : "", documentSearchActiveIndex.value);
}
function handleDocumentViewerDoubleClick(event: MouseEvent) {
const target = event.target;
if (!(target instanceof Element)) return;
const jsonViewer = target.closest(".json-viewer");
if (jsonViewer && target !== jsonViewer) return;
const selection = window.getSelection();
if (selection && !selection.isCollapsed && selection.toString()) return;
startEdit();
}
function handleDocumentBrowserPointerDown(event: PointerEvent) {
const target = event.target;
documentViewerSearchActive.value = target instanceof Element && !!target.closest("[data-document-json-viewer], [data-document-search]");
@ -1590,7 +1702,7 @@ defineExpose({ focusSearch });
</script>
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="h-full flex flex-col overflow-hidden" :class="{ 'select-none': viewMode === 'document' }">
<!-- Top toolbar: view toggle + document count + pagination + actions -->
<div class="h-9 flex items-center gap-1 px-3 border-b shrink-0 text-xs text-muted-foreground">
<div class="flex items-center border rounded-md overflow-hidden mr-2">
@ -1632,13 +1744,118 @@ defineExpose({ focusSearch });
<div class="border-b bg-muted/40 px-3 py-2">
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
</div>
<label class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs hover:bg-accent" :class="{ 'cursor-not-allowed opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<input type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" :checked="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" @change="dataGridRef?.toggleAllNullColumns()" />
<span class="min-w-0 flex items-center gap-1 font-medium">
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<SquareDashed class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.renderMode") }}</span>
</div>
<LightTooltip :text="t('grid.renderModeHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
<button
v-for="mode in ['canvas', 'dom'] as const"
:key="mode"
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="dataGridRenderMode === mode ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setDataGridRenderMode(mode)"
>
{{ t(mode === "canvas" ? "grid.canvasRenderMode" : "grid.domRenderMode") }}
</button>
</div>
</LightTooltip>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<Columns3Cog class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.columnWidth") }}</span>
</div>
<div class="grid w-48 grid-cols-3 rounded-md border bg-muted/40 p-0.5">
<button
v-for="density in ['compact', 'standard', 'comfortable'] as const"
:key="density"
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-1.5 text-xs transition-colors"
:class="columnWidthDensity === density ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setColumnWidthDensity(density)"
>
{{ t(`grid.columnWidth${density.charAt(0).toUpperCase()}${density.slice(1)}`) }}
</button>
</div>
</div>
<DataGridFontFamilyControl />
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-[11px] font-semibold text-muted-foreground">A</span>
<span>{{ t("grid.tableFontSize") }}</span>
</div>
<div class="flex h-6 w-32 items-center rounded-md border bg-muted/40 p-0.5">
<button
type="button"
class="flex h-5 w-8 items-center justify-center rounded-[5px] bg-background text-foreground shadow-sm transition-colors hover:text-foreground disabled:pointer-events-none disabled:bg-muted/40 disabled:text-muted-foreground disabled:opacity-50 disabled:shadow-none"
:disabled="tableFontSize <= TABLE_FONT_SIZE_MIN"
:aria-label="t('common.decrease')"
@click="decreaseTableFontSize"
>
<Minus class="h-3.5 w-3.5" />
</button>
<span class="flex-1 text-center text-xs font-semibold tabular-nums">{{ tableFontSize }}</span>
<button
type="button"
class="flex h-5 w-8 items-center justify-center rounded-[5px] bg-background text-foreground shadow-sm transition-colors hover:text-foreground disabled:pointer-events-none disabled:bg-muted/40 disabled:text-muted-foreground disabled:opacity-50 disabled:shadow-none"
:disabled="tableFontSize >= TABLE_FONT_SIZE_MAX"
:aria-label="t('common.increase')"
@click="increaseTableFontSize"
>
<Plus class="h-3.5 w-3.5" />
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<Rows3 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.transposeMultiRowToggle") }}</span>
</div>
<LightTooltip :text="t('grid.transposeMultiRowHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
<button
v-for="multiRow in [false, true]"
:key="String(multiRow)"
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="dataGridRef?.multiRowTranspose === multiRow ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="dataGridRef?.setMultiRowTranspose(multiRow)"
>
{{ t(multiRow ? "grid.transposeMultiRow" : "grid.transposeSingleRow") }}
</button>
</div>
</LightTooltip>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
<div class="min-w-0 flex items-center gap-2 font-medium">
<component :is="numericColumnRightAlign ? AlignRight : AlignLeft" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{{ t("grid.numericColumnAlign") }}</span>
</div>
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
<button
v-for="rightAlign in [false, true]"
:key="String(rightAlign)"
type="button"
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
:class="numericColumnRightAlign === rightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="setNumericColumnRightAlign(rightAlign)"
>
{{ t(rightAlign ? "grid.numericColumnAlignRight" : "grid.numericColumnAlignLeft") }}
</button>
</div>
</div>
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs" :class="{ 'opacity-60': !dataGridRef?.canToggleAllNullColumns }">
<span class="min-w-0 flex items-center gap-2 font-medium">
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{{ t("grid.hideNullColumns") }}
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
</span>
</label>
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
</div>
<DataGridCopyFormatControl
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
@ -1690,7 +1907,7 @@ defineExpose({ focusSearch });
>
<template #search-bar="{ localFilterCount, hasLocalColumnFilters, localFilterSummaries, clearLocalFilter }: { localFilterCount: number; hasLocalColumnFilters: boolean; localFilterSummaries: LocalFilterSummary[]; clearLocalFilter: (columnIndex?: number) => void }">
<div ref="tableSearchSplitContainerRef" class="flex flex-1 min-w-0">
<div class="flex flex-1 items-start gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0" :style="tableFindPaneStyle">
<Popover v-model:open="documentFilterBuilderOpen">
<PopoverTrigger as-child>
<button
@ -1705,12 +1922,12 @@ defineExpose({ focusSearch });
</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="max-w-[calc(100vw-32px)] gap-3 p-3" :class="documentStoreProvider.kind === 'elasticsearch' ? 'w-[680px]' : 'w-[360px]'">
<div class="flex items-center justify-between gap-3">
<PopoverContent align="start" class="max-w-[calc(100vw-32px)] gap-3 p-3" :class="documentStoreProvider.kind === 'elasticsearch' ? 'w-[680px]' : 'w-[468px]'">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addDocumentFilterRule">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("grid.filterBuilderAddRule") }}
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="clearDocumentFilters(clearLocalFilter)">
<Trash2 class="mr-1 h-3.5 w-3.5" />
{{ t("grid.clearFilter") }}
</Button>
</div>
<div v-if="hasLocalColumnFilters" class="space-y-2 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-2">
@ -1751,7 +1968,7 @@ defineExpose({ focusSearch });
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
class="h-5 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
@click="
updateDocumentFilterRule(rule.id, {
conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND',
@ -1761,7 +1978,7 @@ defineExpose({ focusSearch });
{{ rule.conjunction }}
</Button>
</div>
<div class="grid items-center gap-1.5" :class="documentStoreProvider.kind === 'elasticsearch' ? 'grid-cols-[minmax(0,0.75fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_minmax(0,0.95fr)_minmax(0,1fr)_auto]'">
<div class="grid items-center gap-1.5" :class="documentStoreProvider.kind === 'elasticsearch' ? 'grid-cols-[minmax(0,0.75fr)_minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1fr)_minmax(0,0.9fr)_88px_minmax(0,1fr)_auto]'">
<Select v-if="documentStoreProvider.kind === 'elasticsearch'" :model-value="rule.elasticsearchClause || 'filter'" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { elasticsearchClause: value as ElasticsearchBoolClause })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
@ -1843,20 +2060,33 @@ defineExpose({ focusSearch });
</SelectContent>
</Select>
<Select v-if="documentStoreProvider.kind === 'mongodb'" :model-value="rule.valueType || 'auto'" :disabled="!documentFilterModeNeedsValue(rule.mode)" @update:model-value="(value: any) => updateDocumentFilterRule(rule.id, { valueType: value as DocumentFilterValueType })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem v-for="option in documentFilterValueTypeOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
<Input
v-if="documentStoreProvider.kind === 'elasticsearch' ? elasticsearchQueryTypeNeedsValue(rule.elasticsearchQueryType) : documentFilterModeNeedsValue(rule.mode)"
:model-value="rule.rawValue"
class="h-8 min-w-0 text-xs"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => updateDocumentFilterRule(rule.id, { rawValue: String(value ?? '') })"
@keydown.enter.prevent="applyDocumentStructuredFilters"
@compositionend="endDocumentFilterImeComposition(`value:${rule.id}`)"
@compositionstart="startDocumentFilterImeComposition(`value:${rule.id}`)"
@keydown="handleDocumentFilterValueKeydown($event, rule.id)"
/>
<div v-else class="flex h-8 min-w-0 items-center overflow-hidden rounded-md border border-dashed px-2 text-xs text-muted-foreground">
<span class="truncate">{{ t("grid.filterBuilderNoValue") }}</span>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" :disabled="documentFilterRules.length === 1" @click="removeDocumentFilterRule(rule.id)">
<Trash2 class="h-3.5 w-3.5" />
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive" :disabled="documentFilterRules.length === 1" @click="removeDocumentFilterRule(rule.id)">
<X class="h-3.5 w-3.5" />
</Button>
</div>
</template>
@ -1865,22 +2095,23 @@ defineExpose({ focusSearch });
{{ t("grid.filterBuilderEmpty") }}
</div>
<div class="flex items-center justify-between gap-2 pt-1">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="clearDocumentFilters(clearLocalFilter)">
{{ t("grid.clearFilter") }}
<div class="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="addDocumentFilterRule">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("grid.filterBuilderAddRule") }}
</Button>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="resetDocumentFilterBuilder">
<div class="flex items-center gap-1.5">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="resetDocumentFilterBuilder">
{{ t("grid.resetFilterBuilder") }}
</Button>
<Button size="sm" class="h-8 px-3 text-xs" @click="applyDocumentStructuredFilters">
<Button size="sm" class="h-7 px-3 text-xs" @click="applyDocumentStructuredFilters">
{{ t("grid.applyFilter") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0 pt-0.5">{{ documentStoreProvider.filterInputLabel }}</span>
<span class="text-blue-600 dark:text-blue-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.filterInputLabel }}</span>
<textarea
ref="filterInputRef"
v-model="filterInput"
@ -1894,13 +2125,13 @@ defineExpose({ focusSearch });
@keydown.ctrl.enter.prevent="applyFilter"
@keydown.meta.enter.prevent="applyFilter"
/>
<button v-if="filterInput.trim()" type="button" class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5" title="Format JSON" aria-label="Format JSON" @click="formatFilterInput">
<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" />
</button>
<button
v-if="filterInput.trim()"
type="button"
class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"
class="flex h-5 shrink-0 items-center text-muted-foreground hover:text-foreground"
@click="
filterInput = '';
applyFilter();
@ -1918,8 +2149,8 @@ defineExpose({ focusSearch });
>
<span class="h-5 w-px bg-border group-hover:bg-primary/60" />
</button>
<div class="flex flex-1 items-start gap-1 px-2 py-0.5 min-w-0">
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0 pt-0.5">{{ documentStoreProvider.sortInputLabel }}</span>
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0">
<span class="text-orange-600 dark:text-orange-400 text-xs font-medium select-none shrink-0">{{ documentStoreProvider.sortInputLabel }}</span>
<textarea
ref="sortInputRef"
v-model="sortInput"
@ -1933,13 +2164,13 @@ defineExpose({ focusSearch });
@keydown.ctrl.enter.prevent="applyFilter"
@keydown.meta.enter.prevent="applyFilter"
/>
<button v-if="sortInput.trim()" type="button" class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5" title="Format JSON" aria-label="Format JSON" @click="formatSortInput">
<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" />
</button>
<button
v-if="sortInput.trim()"
type="button"
class="text-muted-foreground hover:text-foreground shrink-0 mt-0.5"
class="flex h-5 shrink-0 items-center text-muted-foreground hover:text-foreground"
@click="
sortInput = '';
applyFilter();
@ -1976,7 +2207,9 @@ defineExpose({ focusSearch });
<div class="h-full flex flex-col min-w-0 overflow-hidden">
<template v-if="selectedIdx !== null || isNew">
<div class="h-9 flex items-center gap-2 px-4 border-b bg-muted/30 shrink-0">
<Badge variant="secondary" class="text-xs">{{ isNew ? "New" : selectedDoc?._id }}</Badge>
<Badge variant="secondary" class="max-w-[50%] rounded text-xs" :style="{ width: selectedDocumentIdWidth }">
<input class="min-w-0 w-full cursor-text select-text appearance-none border-0 bg-transparent p-0 text-inherit outline-none focus:ring-0" :value="selectedDocumentIdLabel" :aria-label="`_id: ${selectedDocumentIdLabel}`" readonly spellcheck="false" />
</Badge>
<span class="flex-1" />
<Button v-if="!isEditing" variant="ghost" size="sm" class="h-6 text-xs" @click="startEdit">{{ t("mongo.edit") }}</Button>
<template v-if="isEditing">
@ -1993,7 +2226,7 @@ defineExpose({ focusSearch });
<div v-if="documentSearchOpen && (!isEditing || documentEditMode === 'json')" data-document-search class="flex h-9 shrink-0 items-center justify-end gap-1 border-b bg-background px-2">
<div class="relative w-56 max-w-[45%] min-w-32">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input ref="documentSearchInputRef" v-model="documentSearchQuery" class="h-7 pl-7 pr-2 text-xs" :placeholder="t('editor.search.find')" @keydown.enter.prevent="activateDocumentSearchMatch($event.shiftKey ? -1 : 1)" @keydown.escape.prevent="closeDocumentSearch" />
<Input ref="documentSearchInputRef" v-model="documentSearchQuery" class="h-7 select-text pl-7 pr-2 text-xs" :placeholder="t('editor.search.find')" @keydown.enter.prevent="activateDocumentSearchMatch($event.shiftKey ? -1 : 1)" @keydown.escape.prevent="closeDocumentSearch" />
</div>
<span class="w-12 text-center text-[11px] tabular-nums text-muted-foreground">{{ documentSearchStatus }}</span>
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('editor.search.prevMatch')" :disabled="documentSearchMatches.length === 0" @click="moveDocumentSearchMatch(-1)">
@ -2012,11 +2245,11 @@ defineExpose({ focusSearch });
</div>
<div v-if="isEditing" class="flex-1 min-h-0 overflow-hidden bg-muted/10">
<div v-if="documentEditMode === 'json'" class="h-full min-h-0 p-2">
<div v-if="documentEditMode === 'json'" class="h-full min-h-0 select-text p-2">
<RedisJsonEditor v-model="editJson" class="h-full rounded border bg-background" :save-disabled="isSavingDocument" :read-only="isSavingDocument" @save="saveDoc" />
</div>
<div v-else class="h-full overflow-auto">
<div class="json-edit min-w-fit p-5" :class="{ 'pointer-events-none opacity-60': isSavingDocument }" :style="{ ...documentFontStyle, '--mongo-key-width': editKeyWidth }" :aria-disabled="isSavingDocument ? 'true' : undefined">
<div class="json-edit min-w-fit select-text p-5" :class="{ 'pointer-events-none opacity-60': isSavingDocument }" :style="{ ...documentFontStyle, '--mongo-key-width': editKeyWidth }" :aria-disabled="isSavingDocument ? 'true' : undefined">
<div class="json-edit-brace">{</div>
<JsonEditNode v-for="(field, idx) in editFields" :key="field.key" :node="field" parent-kind="root" :removable="!isSavingDocument && !field.readonlyValue" @remove="requestRemoveField(idx)" />
@ -2028,8 +2261,8 @@ defineExpose({ focusSearch });
</div>
</div>
<div v-else ref="documentViewerRef" data-document-json-viewer tabindex="-1" class="flex-1 overflow-auto bg-muted/10 outline-none">
<pre class="json-viewer min-w-fit p-5" :style="documentFontStyle" v-html="highlightedJson(editJson)" />
<div v-else ref="documentViewerRef" data-document-json-viewer tabindex="-1" class="flex-1 overflow-auto bg-muted/10 outline-none" @dblclick="handleDocumentViewerDoubleClick">
<pre class="json-viewer min-w-fit select-text p-5" :style="documentFontStyle" v-html="highlightedJson(editJson)" />
</div>
</template>
<div v-else class="h-full flex items-center justify-center text-muted-foreground text-sm">

View File

@ -8,6 +8,21 @@ const backend = vi.hoisted(() => ({
documentFindDocuments: vi.fn(),
cancelQuery: vi.fn(),
ensureConnected: vi.fn(),
documentDeleteDocument: vi.fn(),
}));
const settings = vi.hoisted(() => ({
editorSettings: {
pageSize: 100,
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,
},
updateEditorSettings: vi.fn(),
}));
vi.mock("vue-i18n", async (importOriginal) => ({
@ -19,6 +34,7 @@ vi.mock("@/lib/backend/api", () => ({
getColumns: backend.getColumns,
documentFindDocuments: backend.documentFindDocuments,
cancelQuery: backend.cancelQuery,
documentDeleteDocument: backend.documentDeleteDocument,
}));
vi.mock("@/stores/connectionStore", () => ({
@ -28,13 +44,9 @@ vi.mock("@/stores/connectionStore", () => ({
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
pageSize: 100,
mongoViewMode: "table",
},
updateEditorSettings: vi.fn(),
}),
TABLE_FONT_SIZE_MIN: 8,
TABLE_FONT_SIZE_MAX: 16,
useSettingsStore: () => settings,
}));
vi.mock("@/components/grid/DataGrid.vue", async () => {
@ -66,6 +78,8 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
canToggleAllNullColumns: false,
allNullColumnCount: 0,
toggleAllNullColumns: vi.fn(),
multiRowTranspose: false,
setMultiRowTranspose: vi.fn(),
});
return () =>
h(
@ -76,6 +90,7 @@ vi.mock("@/components/grid/DataGrid.vue", async () => {
"data-database": props.database,
"data-column-layout-scope-key": props.columnLayoutScopeKey,
"data-result-hidden-column-keys": JSON.stringify((props.result as { local_hidden_column_keys?: string[] }).local_hidden_column_keys ?? []),
"data-result-column-types": JSON.stringify((props.result as { column_types?: string[] }).column_types ?? []),
},
[
slots["search-bar"]?.({
@ -220,6 +235,17 @@ beforeEach(async () => {
backend.documentFindDocuments.mockReset();
backend.cancelQuery.mockReset();
backend.ensureConnected.mockReset();
backend.documentDeleteDocument.mockReset();
backend.documentDeleteDocument.mockResolvedValue(undefined);
settings.editorSettings.mongoViewMode = "table";
settings.editorSettings.columnWidthDensity = "standard";
settings.editorSettings.dataGridRenderMode = "canvas";
settings.editorSettings.tableFontFamily = "system-ui";
settings.editorSettings.tableFontSize = 12;
settings.editorSettings.numericColumnRightAlign = true;
settings.editorSettings.confirmDangerousSqlExecution = true;
settings.updateEditorSettings.mockReset();
settings.updateEditorSettings.mockImplementation((partial: Partial<typeof settings.editorSettings>) => Object.assign(settings.editorSettings, partial));
backend.ensureConnected.mockResolvedValue(undefined);
backend.getColumns.mockResolvedValue([
{ name: "buyers", data_type: "nested" },
@ -356,3 +382,227 @@ describe("DocumentBrowser Elasticsearch field search", () => {
expect(document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderSearchColumns"]')?.value).toBe("");
});
});
describe("DocumentBrowser MongoDB filter value types", () => {
it("identifies consistently numeric MongoDB columns for shared grid alignment", async () => {
app?.unmount();
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments.mockResolvedValue({
documents: [
{ _id: "001", amount: 12.5, stringId: "123", mixed: 1, counter: { $numberLong: "9007199254740993" }, optional: null },
{ _id: "002", amount: 8, stringId: "456", mixed: "2", counter: { $numberLong: "9007199254740994" } },
],
raw_documents: [],
total: 2,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "typed_values",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
const dataGrid = root!.querySelector<HTMLElement>('[data-testid="data-grid"]')!;
expect(JSON.parse(dataGrid.dataset.resultColumnTypes ?? "[]")).toEqual(["", "number", "", "", "int64", ""]);
});
it("shows the value type selector and preserves a sampled string _id", async () => {
app?.unmount();
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments.mockResolvedValue({
documents: [{ _id: "1", title: "String id" }],
raw_documents: [],
total: 1,
total_is_exact: true,
});
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "typed_ids",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
root!.querySelector<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
await flushUi();
expect(document.body.querySelector('[data-testid="select"][data-model-value="auto"]')).not.toBeNull();
const clearButton = buttonWithText("grid.clearFilter");
const addButton = buttonWithText("grid.filterBuilderAddRule");
expect(clearButton.className).toContain("h-7");
expect(clearButton.querySelector(".lucide-trash-2")).not.toBeNull();
expect(clearButton.parentElement?.firstElementChild?.textContent).toContain("grid.filter");
expect(addButton.className).toContain("h-7");
expect(addButton.querySelector(".lucide-plus")).not.toBeNull();
expect(addButton.parentElement?.firstElementChild).toBe(addButton);
expect(clearButton.compareDocumentPosition(addButton) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
const removeButton = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.disabled && button.querySelector(".lucide-x"));
expect(removeButton?.className).toContain("h-7");
expect(removeButton?.className).toContain("w-7");
const valueInput = document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderValue"]');
expect(valueInput).not.toBeNull();
valueInput!.value = "1";
valueInput!.dispatchEvent(new Event("input", { bubbles: true }));
await flushUi();
buttonWithText("grid.applyFilter").click();
await flushUi();
const filter = backend.documentFindDocuments.mock.calls.at(-1)?.[5];
expect(JSON.parse(filter)).toEqual({ _id: "1" });
});
it("exposes the applicable shared table view options", async () => {
app?.unmount();
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "typed_ids",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
buttonWithTitle("grid.viewOptions").click();
await flushUi();
expect(document.body.textContent).toContain("grid.renderMode");
expect(document.body.textContent).toContain("grid.tableFontFamily");
expect(document.body.textContent).toContain("grid.tableFontSize");
expect(document.body.textContent).toContain("grid.transposeMultiRowToggle");
expect(document.body.textContent).toContain("grid.numericColumnAlign");
buttonWithText("grid.columnWidthCompact").click();
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ columnWidthDensity: "compact" });
buttonWithText("grid.domRenderMode").click();
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ dataGridRenderMode: "dom" });
buttonWithText("grid.numericColumnAlignLeft").click();
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ numericColumnRightAlign: false });
document.body.querySelector<HTMLButtonElement>('[aria-label="common.increase"]')!.click();
expect(settings.updateEditorSettings).toHaveBeenCalledWith({ tableFontSize: 13 });
});
it("matches SQL value keyboard shortcuts and ignores IME confirmation Enter", async () => {
app?.unmount();
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "typed_ids",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
root!.querySelector<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
await flushUi();
const valueInput = document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderValue"]')!;
const callsBeforeEnter = backend.documentFindDocuments.mock.calls.length;
valueInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushUi();
expect(backend.documentFindDocuments.mock.calls.length).toBeGreaterThan(callsBeforeEnter);
root!.querySelector<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
await flushUi();
const reopenedValueInput = document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderValue"]')!;
reopenedValueInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true }));
await flushUi();
expect(document.body.querySelectorAll('input[placeholder="grid.filterBuilderValue"]')).toHaveLength(2);
expect(document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderSearchColumns"]')).not.toBeNull();
const callsBeforeCompositionEnter = backend.documentFindDocuments.mock.calls.length;
reopenedValueInput.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
reopenedValueInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushUi();
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(callsBeforeCompositionEnter);
});
it("enters document editing only when double-clicking viewer whitespace", async () => {
app?.unmount();
settings.editorSettings.mongoViewMode = "document";
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "typed_ids",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
const documentRow = [...root!.querySelectorAll<HTMLElement>(".group")].find((element) => element.textContent?.includes("document-1"))!;
documentRow.click();
await flushUi();
const viewer = root!.querySelector<HTMLElement>("[data-document-json-viewer]")!;
const jsonText = viewer.querySelector<HTMLElement>(".json-string")!;
const documentId = root!.querySelector<HTMLInputElement>('input[aria-label^="_id:"]')!;
expect(root!.firstElementChild?.classList.contains("select-none")).toBe(true);
expect(documentId.readOnly).toBe(true);
expect(documentId.value).toBe("document-1");
expect(documentId.classList.contains("select-text")).toBe(true);
const documentIdBadge = documentId.closest<HTMLElement>('[data-slot="badge"]');
expect(documentIdBadge).not.toBeNull();
expect(documentIdBadge?.classList.contains("rounded")).toBe(true);
expect(documentIdBadge?.classList.contains("rounded-4xl")).toBe(false);
expect(viewer.querySelector(".json-viewer")?.classList.contains("select-text")).toBe(true);
documentId.setSelectionRange(0, documentId.value.length);
expect(documentId.selectionStart).toBe(0);
expect(documentId.selectionEnd).toBe(documentId.value.length);
expect(window.getSelection()?.toString()).toBe("");
jsonText.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await flushUi();
expect(buttonWithText("mongo.edit")).toBeDefined();
viewer.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
await flushUi();
expect(buttonWithText("grid.save")).toBeDefined();
});
it("deletes a document without opening the danger dialog when confirmation is disabled", async () => {
app?.unmount();
settings.editorSettings.mongoViewMode = "document";
settings.editorSettings.confirmDangerousSqlExecution = false;
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "typed_ids",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
root!.querySelector<HTMLElement>(".lucide-trash-2")!.closest<HTMLButtonElement>("button")!.click();
await flushUi();
expect(backend.documentDeleteDocument).toHaveBeenCalledOnce();
expect(backend.documentDeleteDocument).toHaveBeenCalledWith("mongo-1", "test", "typed_ids", '__dbx_mongo_string_id__"document-1"', undefined, undefined);
});
it("waits for danger confirmation before deleting a document when confirmation is enabled", async () => {
app?.unmount();
settings.editorSettings.mongoViewMode = "document";
settings.editorSettings.confirmDangerousSqlExecution = true;
app = createApp(DocumentBrowser, {
connectionId: "mongo-1",
database: "test",
collection: "typed_ids",
databaseType: "mongodb",
});
app.mount(root!);
await flushUi();
root!.querySelector<HTMLElement>(".lucide-trash-2")!.closest<HTMLButtonElement>("button")!.click();
await flushUi();
expect(backend.documentDeleteDocument).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("dangerDialog.deleteMessage");
});
});

View File

@ -2986,6 +2986,7 @@ const editor = useDataGridEditor({
orderByInput,
rowStatusFilter,
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution),
initialEditColumn: firstVisibleColumnIndex,
getRowItem,
pageSize,

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import { Eye, EyeOff, Plus, Search, Trash2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@ -14,6 +14,11 @@ const { t } = useI18n();
const VALUE_SHORTCUT_HINT_STORAGE_KEY = "dbx-filter-builder-value-shortcut-hint-days";
const VALUE_SHORTCUT_HINT_MAX_DAYS = 3;
const VALUE_SHORTCUT_HINT_MAX_PER_DAY = 2;
const IME_COMPOSITION_END_GRACE_MS = 120;
const COLUMN_CONTROL_MIN_WIDTH = 88;
const COLUMN_CONTROL_MAX_WIDTH = 178;
const COLUMN_CONTROL_CHROME_WIDTH = 40;
const COLUMN_CHARACTER_WIDTH = 6.5;
type ValueShortcutHintDay = { date: string; count: number };
const props = withDefaults(
defineProps<{
@ -46,8 +51,23 @@ const activeColumnIndexes = ref<Record<string, number>>({});
const focusedValueRuleId = ref<string>();
const valueShortcutHintRuleId = ref<string>();
const valueShortcutHintShownDays = ref(readValueShortcutHintShownDays());
const composingEditors = new Set<string>();
const compositionEndedAt = new Map<string, number>();
let ruleIdsBeforeKeyboardAdd: Set<string> | undefined;
function columnNameDisplayUnits(value: string) {
return Array.from(value).reduce((total, character) => total + ((character.codePointAt(0) ?? 0) > 0xff ? 2 : 1), 0);
}
const filterBuilderStyle = computed(() => {
const longestColumnUnits = Math.max(0, ...props.columns.map(columnNameDisplayUnits));
const columnWidth = Math.min(COLUMN_CONTROL_MAX_WIDTH, Math.max(COLUMN_CONTROL_MIN_WIDTH, Math.ceil(longestColumnUnits * COLUMN_CHARACTER_WIDTH + COLUMN_CONTROL_CHROME_WIDTH)));
return {
"--filter-builder-column-width": `${columnWidth}px`,
"--filter-builder-value-width": `${COLUMN_CONTROL_MAX_WIDTH}px`,
};
});
function usesExpandedLayout(mode: DataGridContextFilterMode) {
return filterModeUsesList(mode) || filterModeUsesRange(mode);
}
@ -92,7 +112,7 @@ function setColumnSearchInput(id: string, element: unknown) {
return;
}
columnSearchInputs.set(id, element);
if (openColumnSelectIds.value.has(id)) window.requestAnimationFrame(() => element.focus());
if (openColumnSelectIds.value.has(id)) window.requestAnimationFrame(() => element.focus({ preventScroll: true }));
}
function setFilterRuleElement(id: string, element: unknown) {
@ -104,14 +124,16 @@ function activeColumnIndex(id: string): number {
return activeColumnIndexes.value[id] ?? -1;
}
function setActiveColumnIndex(id: string, index: number) {
function scrollColumnIntoView(id: string, index: number, block: ScrollLogicalPosition) {
const listbox = columnSearchInputs.get(id)?.closest('[role="listbox"]');
listbox?.querySelectorAll<HTMLElement>('[role="option"]')[index]?.scrollIntoView?.({ block });
}
function setActiveColumnIndex(id: string, index: number, scrollBlock: ScrollLogicalPosition = "nearest") {
const count = props.filteredColumns.length;
const nextIndex = count ? ((index % count) + count) % count : -1;
activeColumnIndexes.value = { ...activeColumnIndexes.value, [id]: nextIndex };
window.requestAnimationFrame(() => {
const listbox = columnSearchInputs.get(id)?.closest('[role="listbox"]');
listbox?.querySelectorAll<HTMLElement>('[role="option"]')[nextIndex]?.scrollIntoView?.({ block: "nearest" });
});
window.requestAnimationFrame(() => scrollColumnIntoView(id, nextIndex, scrollBlock));
}
function setColumnSelectOpen(id: string, open: boolean) {
@ -129,9 +151,13 @@ async function handleColumnSelectOpen(rule: DataGridStructuredFilterRule, open:
setColumnSelectOpen(rule.id, open);
if (!open) return;
const selectedIndex = props.filteredColumns.indexOf(rule.columnName);
setActiveColumnIndex(rule.id, selectedIndex >= 0 ? selectedIndex : 0);
const nextIndex = selectedIndex >= 0 ? selectedIndex : 0;
setActiveColumnIndex(rule.id, nextIndex, "start");
await nextTick();
window.requestAnimationFrame(() => columnSearchInputs.get(rule.id)?.focus());
window.requestAnimationFrame(() => {
columnSearchInputs.get(rule.id)?.focus({ preventScroll: true });
window.requestAnimationFrame(() => scrollColumnIntoView(rule.id, nextIndex, "start"));
});
}
async function openFirstEmptyRuleColumnSearch() {
@ -188,6 +214,23 @@ function moveColumnSearchCaret(event: KeyboardEvent) {
input.setSelectionRange(nextPosition, nextPosition);
}
function startImeComposition(editorKey: string) {
composingEditors.add(editorKey);
compositionEndedAt.delete(editorKey);
}
function endImeComposition(editorKey: string) {
composingEditors.delete(editorKey);
compositionEndedAt.set(editorKey, Date.now());
}
function isImeCompositionKey(event: KeyboardEvent, editorKey: string) {
const endedAt = compositionEndedAt.get(editorKey);
const justEnded = event.key === "Enter" && endedAt !== undefined && Date.now() - endedAt <= IME_COMPOSITION_END_GRACE_MS;
if (justEnded || (endedAt !== undefined && event.key !== "Process")) compositionEndedAt.delete(editorKey);
return event.isComposing || event.key === "Process" || event.keyCode === 229 || composingEditors.has(editorKey) || justEnded;
}
watch(
() => props.rules,
(rules) => {
@ -199,7 +242,7 @@ watch(
);
function handleColumnSearchKeydown(event: KeyboardEvent, rule: DataGridStructuredFilterRule) {
if (event.isComposing || event.key === "Process") {
if (isImeCompositionKey(event, `column:${rule.id}`)) {
event.stopPropagation();
return;
}
@ -227,8 +270,12 @@ function handleColumnSearchKeydown(event: KeyboardEvent, rule: DataGridStructure
if (!event.ctrlKey && !event.metaKey && !event.altKey && (event.key.length === 1 || event.key === "Backspace" || event.key === "Delete")) event.stopPropagation();
}
function handleValueEditorKeydown(event: KeyboardEvent) {
if (event.key !== "Enter" || event.isComposing) return;
function handleValueEditorKeydown(event: KeyboardEvent, editorKey: string) {
if (isImeCompositionKey(event, editorKey)) {
event.stopPropagation();
return;
}
if (event.key !== "Enter") return;
event.preventDefault();
if (!event.shiftKey) {
emit("apply");
@ -259,20 +306,20 @@ function blurValueRule(id: string) {
</script>
<template>
<div class="space-y-3">
<div v-if="props.showHeader !== false" class="flex items-center justify-between gap-3">
<div class="w-fit max-w-full space-y-2" :style="filterBuilderStyle">
<div v-if="props.showHeader !== false" class="flex items-center justify-between gap-2">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('clear')"> <Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }} </Button>
</div>
<div v-if="props.rules.length" class="space-y-2">
<div v-if="props.rules.length" class="space-y-1.5">
<template v-for="(rule, index) in props.rules" :key="rule.id">
<div v-if="index > 0" class="flex justify-center">
<Button variant="ghost" size="sm" class="h-6 px-2 text-[11px]" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
<Button variant="ghost" size="sm" class="h-5 px-2 text-[11px]" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
</div>
<div :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid items-center justify-start gap-2" :class="usesExpandedLayout(rule.mode) ? 'grid-cols-[minmax(0,260px)_92px_auto]' : 'grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]'">
<div :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto] items-center justify-start gap-1.5">
<Select :model-value="rule.columnName" :open="openColumnSelectIds.has(rule.id)" :disabled="rule.disabled" @update:model-value="(value: any) => updateRuleColumn(rule, value)" @update:open="(open: boolean) => handleColumnSelectOpen(rule, open)">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectTrigger size="sm" class="h-7 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectValue v-if="rule.columnName">{{ rule.columnName }}</SelectValue>
<SelectValue v-else :placeholder="t('grid.filterBuilderColumn')" />
</SelectTrigger>
@ -289,48 +336,56 @@ function blurValueRule(id: string) {
{{ column }}
</SelectItem>
<div v-if="!props.filteredColumns.length" class="px-2 py-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoMatchingColumns") }}</div>
<div class="sticky bottom-0 mt-1 flex items-center gap-1.5 border-t bg-popover px-2 py-1.5">
<Search class="h-3.5 w-3.5 text-muted-foreground" />
<input
:ref="(element) => setColumnSearchInput(rule.id, element)"
:value="props.columnSearch"
class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none"
:placeholder="t('grid.filterBuilderSearchColumns')"
@input="updateColumnSearch(rule.id, $event)"
@click.stop
@keydown="handleColumnSearchKeydown($event, rule)"
@pointerdown.stop
/>
</div>
<template #footer>
<div class="flex items-center gap-1.5 border-t bg-popover px-2 py-1">
<Search class="h-3.5 w-3.5 text-muted-foreground" />
<input
:ref="(element) => setColumnSearchInput(rule.id, element)"
:value="props.columnSearch"
class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none"
:placeholder="t('grid.filterBuilderSearchColumns')"
@input="updateColumnSearch(rule.id, $event)"
@click.stop
@compositionend="endImeComposition(`column:${rule.id}`)"
@compositionstart="startImeComposition(`column:${rule.id}`)"
@keydown="handleColumnSearchKeydown($event, rule)"
@pointerdown.stop
/>
</div>
</template>
</SelectContent>
</Select>
<Select :model-value="rule.mode" :disabled="rule.disabled" @update:model-value="(value: any) => emit('updateRule', rule.id, { mode: value as DataGridContextFilterMode })">
<SelectTrigger class="h-8 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate"><SelectValue /></SelectTrigger>
<SelectTrigger size="sm" class="h-7 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate"><SelectValue /></SelectTrigger>
<SelectContent
><SelectItem v-for="option in props.modeOptions" :key="option.value" :value="option.value" class="rounded-none">{{ t(option.labelKey) }}</SelectItem></SelectContent
>
</Select>
<div v-if="filterModeUsesRange(rule.mode)" class="col-span-2 flex gap-2">
<div v-if="filterModeUsesRange(rule.mode)" class="col-span-3 flex gap-1.5">
<Input
data-filter-value-editor
:model-value="rule.rawValue"
class="h-8 text-xs"
class="h-7 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeStart')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
@compositionend="endImeComposition(`value-start:${rule.id}`)"
@compositionstart="startImeComposition(`value-start:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
/>
<Input
:model-value="rule.rawEndValue"
class="h-8 text-xs"
class="h-7 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeEnd')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
@compositionend="endImeComposition(`value-end:${rule.id}`)"
@compositionstart="startImeComposition(`value-end:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
/>
</div>
<textarea
@ -338,7 +393,7 @@ function blurValueRule(id: string) {
data-filter-value-editor
:value="rule.rawValue"
rows="2"
class="col-span-2 min-h-14 resize-y rounded-md border bg-transparent px-2.5 py-1 text-xs outline-none"
class="col-span-3 min-h-12 resize-y rounded-md border bg-transparent px-2 py-1 text-xs outline-none"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValues')"
@input="emit('updateRule', rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
@ -349,31 +404,33 @@ function blurValueRule(id: string) {
v-else-if="filterModeNeedsValue(rule.mode)"
data-filter-value-editor
:model-value="rule.rawValue"
class="h-8 text-xs"
class="h-7 text-xs"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@focus="focusValueRule(rule.id, index, rule.mode)"
@blur="blurValueRule(rule.id)"
@keydown="handleValueEditorKeydown"
@compositionend="endImeComposition(`value:${rule.id}`)"
@compositionstart="startImeComposition(`value:${rule.id}`)"
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
/>
<div v-else class="flex h-8 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
<div v-if="shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-span-2 -mt-1' : 'col-start-3 row-start-2 -mt-1'">
<div v-else class="flex h-7 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
<div v-if="shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-span-3 -mt-0.5' : 'col-start-3 row-start-2 -mt-0.5'">
{{ t("grid.filterBuilderValueShortcutHint") }}
</div>
<div class="flex items-center gap-1" :class="usesExpandedLayout(rule.mode) ? 'col-start-3 row-start-1 row-span-2' : 'col-start-4 row-start-1'">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="emit('updateRule', rule.id, { disabled: !rule.disabled })"><EyeOff v-if="rule.disabled" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-8 w-8" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
<div class="flex items-center gap-0.5" :class="usesExpandedLayout(rule.mode) ? 'col-start-4 row-start-1 row-span-2' : 'col-start-4 row-start-1'">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('updateRule', rule.id, { disabled: !rule.disabled })"><EyeOff v-if="rule.disabled" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
</div>
</div>
</template>
</div>
<div v-else class="rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">{{ t("grid.filterBuilderEmpty") }}</div>
<div v-if="props.showFooter !== false" class="flex justify-between gap-2 pt-1">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" :disabled="props.disabled || !props.columns.length" @click="emit('add')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</Button>
<div class="flex gap-2">
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" @click="emit('reset')">{{ t("grid.resetFilterBuilder") }}</Button
><Button size="sm" class="h-8 px-3 text-xs" :disabled="props.disabled" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
<div v-else class="rounded-md border border-dashed px-3 py-3 text-center text-xs text-muted-foreground">{{ t("grid.filterBuilderEmpty") }}</div>
<div v-if="props.showFooter !== false" class="flex justify-between gap-2">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" :disabled="props.disabled || !props.columns.length" @click="emit('add')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</Button>
<div class="flex gap-1.5">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('reset')">{{ t("grid.resetFilterBuilder") }}</Button
><Button size="sm" class="h-7 px-3 text-xs" :disabled="props.disabled" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
</div>
</div>
</div>

View File

@ -160,21 +160,21 @@ onUnmounted(onResizeEnd);
<span v-if="filterButtonCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">{{ filterButtonCount }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-[624px] max-w-[calc(100vw-24px)] gap-3 p-3">
<div class="flex items-center justify-between gap-3">
<PopoverContent align="start" class="w-fit max-w-[calc(100vw-16px)] gap-2 p-2.5">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('clearFilters')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }}</Button>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="emit('clearFilters')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }}</Button>
</div>
<div v-if="hasLocalColumnFilters" class="space-y-2 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-2">
<div class="flex items-center justify-between gap-3">
<div v-if="hasLocalColumnFilters" class="space-y-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2 text-xs font-medium text-primary">
<Filter class="h-3.5 w-3.5 shrink-0" /><span class="truncate">{{ t("grid.localFiltersActive", { count: localFilterCount }) }}</span>
</div>
<Button variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-xs" @click="emit('clearLocalFilter')"><X class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearLocalFiltersShort") }}</Button>
<Button variant="ghost" size="sm" class="h-6 shrink-0 px-2 text-xs" @click="emit('clearLocalFilter')"><X class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearLocalFiltersShort") }}</Button>
</div>
<div class="space-y-1">
<div v-for="summary in localFilterSummaries" :key="summary.columnIndex" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-2 rounded border border-primary/10 bg-background/70 px-2 py-1 text-xs">
<div class="space-y-0.5">
<div v-for="summary in localFilterSummaries" :key="summary.columnIndex" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-1.5 rounded border border-primary/10 bg-background/70 px-2 py-0.5 text-xs">
<span class="truncate font-medium text-foreground" :title="summary.columnName">{{ summary.columnName }}</span>
<span class="min-w-0 truncate font-mono text-muted-foreground">
<template v-for="(value, valueIndex) in summary.values" :key="valueIndex"
@ -182,7 +182,7 @@ onUnmounted(onResizeEnd);
>
<span v-if="summary.hiddenValueCount">{{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }}</span>
</span>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-destructive" :title="t('grid.clearFilter')" @click="emit('clearLocalFilter', summary.columnIndex)"><X class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-5 w-5 text-muted-foreground hover:text-destructive" :title="t('grid.clearFilter')" @click="emit('clearLocalFilter', summary.columnIndex)"><X class="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>

View File

@ -415,7 +415,8 @@ describe("DataGridFilterBuilder", () => {
const triggers = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectTrigger");
const selectValues = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectValue");
const items = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem");
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]"));
const filterBuilder = findOne(mounted.root, (node) => String(node.props.class).includes("w-fit max-w-full"));
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]"));
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
@ -429,7 +430,8 @@ describe("DataGridFilterBuilder", () => {
expect(items.every((item) => String(item.props.class).includes("rounded-none"))).toBe(true);
expect(searchInput.props.placeholder).toBe("grid.filterBuilderSearchColumns");
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderValue");
expect(String(ruleGrid.props.class)).toContain("grid-cols-[minmax(0,210px)_92px_minmax(0,210px)_auto]");
expect(filterBuilder.props.style).toEqual({ "--filter-builder-column-width": "178px", "--filter-builder-value-width": "178px" });
expect(String(ruleGrid.props.class)).toContain("grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]");
expect(String(ruleGrid.props.class)).toContain("justify-start");
for (const trigger of triggers) {
expect(String(trigger.props.class)).toContain("w-full");
@ -439,6 +441,19 @@ describe("DataGridFilterBuilder", () => {
}
});
it("sizes the column control from the longest available column", () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "id", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["id", "name"],
filteredColumns: ["id", "name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
});
const filterBuilder = findOne(mounted.root, (node) => String(node.props.class).includes("w-fit max-w-full"));
expect(filterBuilder.props.style).toEqual({ "--filter-builder-column-width": "88px", "--filter-builder-value-width": "178px" });
});
it("keeps search focus while navigating and selecting filtered columns", async () => {
const onUpdateRule = vi.fn();
const onAdd = vi.fn();
@ -459,6 +474,15 @@ describe("DataGridFilterBuilder", () => {
let columnItems = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem").slice(0, 2);
expect(columnItems[0].props["data-filter-active"]).toBe("");
const imeKeyCodeEnter = dispatch(searchInput, "keydown", { key: "Enter", keyCode: 229 });
expect(imeKeyCodeEnter.defaultPrevented).toBe(false);
expect(imeKeyCodeEnter.propagationStopped).toBe(true);
dispatch(searchInput, "compositionstart");
dispatch(searchInput, "compositionend");
const imeCompositionEndEnter = dispatch(searchInput, "keydown", { key: "Enter", keyCode: 13 });
expect(imeCompositionEndEnter.defaultPrevented).toBe(false);
expect(imeCompositionEndEnter.propagationStopped).toBe(true);
expect(onUpdateRule).not.toHaveBeenCalled();
expect(dispatch(searchInput, "keydown", { key: "a" }).propagationStopped).toBe(true);
expect(dispatch(searchInput, "keydown", { key: "Backspace" }).propagationStopped).toBe(true);
@ -597,6 +621,17 @@ describe("DataGridFilterBuilder", () => {
await nextTick();
expect(hostText(exhaustedMounted.root)).not.toContain("grid.filterBuilderValueShortcutHint");
const imeKeyCodeEnter = dispatch(secondValueEditor, "keydown", { key: "Enter", keyCode: 229 });
expect(imeKeyCodeEnter.defaultPrevented).toBe(false);
expect(imeKeyCodeEnter.propagationStopped).toBe(true);
dispatch(secondValueEditor, "compositionstart");
dispatch(secondValueEditor, "compositionend");
const imeCompositionEndEnter = dispatch(secondValueEditor, "keydown", { key: "Enter", keyCode: 13 });
expect(imeCompositionEndEnter.defaultPrevented).toBe(false);
expect(imeCompositionEndEnter.propagationStopped).toBe(true);
expect(onAdd).not.toHaveBeenCalled();
expect(onApply).not.toHaveBeenCalled();
const shiftEnter = dispatch(secondValueEditor, "keydown", { key: "Enter", shiftKey: true, repeat: false });
expect(shiftEnter.defaultPrevented).toBe(true);
expect(shiftEnter.propagationStopped).toBe(true);
@ -739,8 +774,8 @@ describe("DataGridQueryControls", () => {
});
const popoverContent = findOne(mounted.root, (node) => node.props["data-stub"] === "PopoverContent");
expect(String(popoverContent.props.class)).toContain("w-[624px]");
expect(String(popoverContent.props.class)).toContain("max-w-[calc(100vw-24px)]");
expect(String(popoverContent.props.class)).toContain("w-fit");
expect(String(popoverContent.props.class)).toContain("max-w-[calc(100vw-16px)]");
});
it("keeps filter actions available in the popover", () => {

View File

@ -110,7 +110,7 @@ export function createPassthroughStub(name: string, tag = "div") {
name,
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h(tag, { ...attrs, "data-stub": name }, [slots.default?.(), slots.content?.()]);
return () => h(tag, { ...attrs, "data-stub": name }, [slots.default?.(), slots.content?.(), slots.footer?.()]);
},
});
}

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { useSlots } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/common/utils";
@ -27,6 +28,7 @@ const props = withDefaults(
},
);
const emits = defineEmits<SelectContentEmits>();
const slots = useSlots();
const delegatedProps = reactiveOmit(props, "class", "disablePortal", "hideScrollButtons");
@ -41,16 +43,20 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none',
'text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 cn-menu-translucent relative z-50 max-h-(--reka-select-content-available-height) origin-(--reka-select-content-transform-origin) overflow-x-hidden data-[align-trigger=true]:animate-none',
slots.footer ? 'flex flex-col overflow-y-hidden' : 'overflow-y-auto',
position === 'popper' && 'w-fit min-w-[var(--reka-select-trigger-width)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton v-if="!hideScrollButtons" />
<SelectViewport :data-position="position" :class="cn('data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full')">
<SelectViewport :data-position="position" :class="cn('data-[position=popper]:h-[var(--reka-select-trigger-height)] data-[position=popper]:w-full', slots.footer && 'min-h-0 overflow-y-auto overscroll-none')">
<slot />
</SelectViewport>
<div v-if="slots.footer" class="shrink-0">
<slot name="footer" />
</div>
<SelectScrollDownButton v-if="!hideScrollButtons" />
</SelectContent>
</SelectPortal>

View File

@ -18,7 +18,7 @@ vi.mock("@/stores/productionSafetyStore", () => ({
useProductionSafetyStore: () => ({}),
}));
function createEditor(sourceColumns?: Array<string | undefined>) {
function createEditor(sourceColumns?: Array<string | undefined>, confirmDangerousRowDeletion = true) {
let editor: ReturnType<typeof useDataGridEditor>;
const result = ref<{ columns: string[]; rows: CellValue[][] }>({
columns: ["first", "hidden", "last"],
@ -48,6 +48,7 @@ function createEditor(sourceColumns?: Array<string | undefined>) {
currentWhereInput: computed(() => undefined),
orderByInput: ref(""),
rowStatusFilter: ref("all"),
confirmDangerousRowDeletion: computed(() => confirmDangerousRowDeletion),
pageSize: ref(100),
currentPage: ref(1),
getRowItem: (rowId) => {
@ -82,6 +83,29 @@ function createEditor(sourceColumns?: Array<string | undefined>) {
return editor;
}
describe("useDataGridEditor row deletion confirmation", () => {
it("keeps the row pending until confirmation when confirmation is enabled", () => {
const editor = createEditor(undefined, true);
editor.requestDeleteRow(-1);
expect(editor.showDeleteRowConfirm.value).toBe(true);
expect(editor.newRows.value).toHaveLength(1);
editor.confirmDeleteRow();
expect(editor.newRows.value).toHaveLength(0);
});
it("applies row deletion immediately when confirmation is disabled", () => {
const editor = createEditor(undefined, false);
editor.requestDeleteRow(-1);
expect(editor.showDeleteRowConfirm.value).toBe(false);
expect(editor.newRows.value).toHaveLength(0);
});
});
describe("useDataGridEditor appendPastedRowsToNewRow", () => {
beforeEach(() => {
mocks.getConfig.mockReturnValue({ id: "connection-1", db_type: "postgres" });

View File

@ -96,6 +96,7 @@ export interface UseDataGridEditorOptions {
orderByInput: Ref<string>;
rowStatusFilter: Ref<RowStatusFilter>;
dataGridQuickEntryEnabled?: ComputedRef<boolean>;
confirmDangerousRowDeletion?: ComputedRef<boolean>;
initialEditColumn?: ComputedRef<number>;
getRowItem: (rowId: number) => RowItem | undefined;
pageSize: Ref<number>;
@ -202,6 +203,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
orderByInput,
rowStatusFilter,
dataGridQuickEntryEnabled = computed(() => false),
confirmDangerousRowDeletion = computed(() => true),
initialEditColumn,
getRowItem,
pageSize,
@ -1200,11 +1202,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const pendingDeleteRowIds = ref<number[]>([]);
function requestDeleteRow(rowId: number) {
if (!confirmDangerousRowDeletion.value) {
applyDeleteRow(rowId);
return;
}
pendingDeleteRowId.value = rowId;
showDeleteRowConfirm.value = true;
}
function requestDeleteRows(rowIds: number[]) {
if (!confirmDangerousRowDeletion.value) {
applyDeleteRows(rowIds);
return;
}
pendingDeleteRowIds.value = rowIds;
showDeleteRowConfirm.value = true;
}

View File

@ -1304,6 +1304,16 @@ export default {
filterBuilderSearchColumns: "Search columns...",
filterBuilderNoMatchingColumns: "No matching columns",
filterBuilderValue: "Value",
filterBuilderValueTypeAuto: "Auto",
filterBuilderValueTypeString: "String",
filterBuilderValueTypeNumber: "Number",
filterBuilderValueTypeBoolean: "Boolean",
filterBuilderValueTypeObjectId: "ObjectId",
filterBuilderValueTypeDate: "Date",
filterBuilderValueTypeInt32: "Int32",
filterBuilderValueTypeInt64: "Int64",
filterBuilderValueTypeDecimal128: "Decimal128",
filterBuilderValueTypeJson: "JSON",
filterBuilderValueShortcutHint: "Shift+Enter can add a rule",
filterBuilderValues: "Values (comma or newline separated)",
filterBuilderRangeStart: "Start value",
@ -5309,8 +5319,8 @@ export default {
insertSpaceAfterCompletionDescription: "Append a space after accepting a keyword, table, or column completion when the next character allows it",
sqlSemanticDiagnosticsEnabled: "SQL semantic diagnostics",
sqlSemanticDiagnosticsEnabledDescription: "When enabled, the editor reports semantic issues such as unknown tables and columns. Disable it to reduce parsing and metadata checks for large SQL.",
confirmDangerousSqlExecution: "Confirm before dangerous SQL",
confirmDangerousSqlExecutionDescription: "When disabled, ALTER, DROP, DELETE, TRUNCATE, and other dangerous SQL run without the warning dialog.",
confirmDangerousSqlExecution: "Confirm before dangerous operations",
confirmDangerousSqlExecutionDescription: "When disabled, dangerous SQL and table row or document deletions run without the warning dialog.",
continueOnErrorOnBatch: "Continue on Error",
continueOnErrorOnBatchDescription: "When enabled, multi-statement execution continues after an error instead of stopping at the first failure.",
confirmUnsavedSqlClose: "Confirm before closing unsaved SQL",

View File

@ -1196,6 +1196,16 @@ export default withEnglishFallback({
filterBuilderAddRule: "Agregar regla",
filterBuilderColumn: "Columna",
filterBuilderValue: "Valor",
filterBuilderValueTypeAuto: "Automático",
filterBuilderValueTypeString: "Cadena",
filterBuilderValueTypeNumber: "Número",
filterBuilderValueTypeBoolean: "Booleano",
filterBuilderValueTypeObjectId: "ObjectId",
filterBuilderValueTypeDate: "Fecha",
filterBuilderValueTypeInt32: "Int32",
filterBuilderValueTypeInt64: "Int64",
filterBuilderValueTypeDecimal128: "Decimal128",
filterBuilderValueTypeJson: "JSON",
filterBuilderValueShortcutHint: "Shift+Enter puede agregar una regla",
filterBuilderValues: "Valores (separados por comas o saltos de línea)",
filterBuilderRangeStart: "Valor inicial",
@ -5038,8 +5048,8 @@ export default withEnglishFallback({
autoCloseBracketsDescription: "Insertar automáticamente paréntesis y comillas de cierre al escribir los de apertura",
sqlSemanticDiagnosticsEnabled: "Diagnóstico semántico de SQL",
sqlSemanticDiagnosticsEnabledDescription: "Al activarse, el editor informa de problemas semánticos como tablas y columnas desconocidas. Desactívalo para reducir el análisis y las comprobaciones de metadatos en SQL grandes.",
confirmDangerousSqlExecution: "Confirmar antes de SQL peligroso",
confirmDangerousSqlExecutionDescription: "Cuando se desactiva, ALTER, DROP, DELETE, TRUNCATE y otras sentencias peligrosas se ejecutan sin el diálogo de advertencia.",
confirmDangerousSqlExecution: "Confirmar antes de operaciones peligrosas",
confirmDangerousSqlExecutionDescription: "Cuando se desactiva, el SQL peligroso y la eliminación de filas o documentos se ejecutan sin el diálogo de advertencia.",
continueOnErrorOnBatch: "Continuar en error",
continueOnErrorOnBatchDescription: "Cuando está habilitado, la ejecución de múltiples sentencias continúa después de un error en lugar de detenerse en el primer fallo.",
confirmUnsavedSqlClose: "Confirmar antes de cerrar SQL sin guardar",

View File

@ -1194,6 +1194,16 @@ export default withEnglishFallback({
filterBuilderAddRule: "Aggiungi regola",
filterBuilderColumn: "Colonna",
filterBuilderValue: "Valore",
filterBuilderValueTypeAuto: "Automatico",
filterBuilderValueTypeString: "Stringa",
filterBuilderValueTypeNumber: "Numero",
filterBuilderValueTypeBoolean: "Booleano",
filterBuilderValueTypeObjectId: "ObjectId",
filterBuilderValueTypeDate: "Data",
filterBuilderValueTypeInt32: "Int32",
filterBuilderValueTypeInt64: "Int64",
filterBuilderValueTypeDecimal128: "Decimal128",
filterBuilderValueTypeJson: "JSON",
filterBuilderValueShortcutHint: "Shift+Enter può aggiungere una regola",
filterBuilderValues: "Valori (separati da virgole o nuove righe)",
filterBuilderRangeStart: "Valore iniziale",
@ -5038,8 +5048,8 @@ export default withEnglishFallback({
autoCloseBracketsDescription: "Inserisci automaticamente parentesi e virgolette di chiusura quando digiti quelle di apertura",
sqlSemanticDiagnosticsEnabled: "Diagnostica semantica SQL",
sqlSemanticDiagnosticsEnabledDescription: "Se abilitata, l'editor segnala problemi semantici come tabelle e colonne sconosciute. Disabilitala per ridurre l'analisi e i controlli dei metadati per file SQL di grandi dimensioni.",
confirmDangerousSqlExecution: "Conferma prima di SQL pericoloso",
confirmDangerousSqlExecutionDescription: "Se disattivato, ALTER, DROP, DELETE, TRUNCATE e altri SQL pericolosi verranno eseguiti senza la finestra di avviso.",
confirmDangerousSqlExecution: "Conferma prima delle operazioni pericolose",
confirmDangerousSqlExecutionDescription: "Se disattivato, SQL pericoloso ed eliminazioni di righe o documenti vengono eseguiti senza la finestra di avviso.",
continueOnErrorOnBatch: "Continua in caso di errore",
continueOnErrorOnBatchDescription: "Se abilitato, l'esecuzione di più istruzioni continua dopo un errore invece di interrompersi al primo fallimento.",
confirmUnsavedSqlClose: "Conferma prima di chiudere SQL non salvato",

View File

@ -1212,6 +1212,16 @@ export default withEnglishFallback({
filterBuilderAddRule: "ルールを追加",
filterBuilderColumn: "列",
filterBuilderValue: "値",
filterBuilderValueTypeAuto: "自動",
filterBuilderValueTypeString: "文字列",
filterBuilderValueTypeNumber: "数値",
filterBuilderValueTypeBoolean: "真偽値",
filterBuilderValueTypeObjectId: "ObjectId",
filterBuilderValueTypeDate: "日付",
filterBuilderValueTypeInt32: "Int32",
filterBuilderValueTypeInt64: "Int64",
filterBuilderValueTypeDecimal128: "Decimal128",
filterBuilderValueTypeJson: "JSON",
filterBuilderValueShortcutHint: "Shift+Enterで条件を追加できます",
filterBuilderValues: "値(カンマまたは改行で区切る)",
filterBuilderRangeStart: "開始値",
@ -5067,8 +5077,8 @@ export default withEnglishFallback({
autoCloseBracketsDescription: "左括弧や左引用符を入力すると、対応する右括弧や右引用符を自動的に挿入します",
sqlSemanticDiagnosticsEnabled: "SQLセマンティック診断",
sqlSemanticDiagnosticsEnabledDescription: "有効時、エディタは不明なテーブルや列などの意味的な問題を表示します。大きなSQLの解析とメタデータ確認の負荷を減らすには無効にします。",
confirmDangerousSqlExecution: "危険なSQLの前に確認",
confirmDangerousSqlExecutionDescription: "無効時、ALTER、DROP、DELETE、TRUNCATEなどの危険なSQLが警告ダイアログなしで実行されます。",
confirmDangerousSqlExecution: "危険な操作の前に確認",
confirmDangerousSqlExecutionDescription: "無効時、危険なSQLおよびテーブル行やドキュメントの削除が警告ダイアログなしで実行されます。",
continueOnErrorOnBatch: "エラー時も継続",
continueOnErrorOnBatchDescription: "有効にすると、複数SQL文の実行時にエラーが発生しても、最初の失敗で停止せずに後続の文を実行し続けます。",
confirmUnsavedSqlClose: "未保存のSQLを閉じる前に確認",

View File

@ -1208,6 +1208,16 @@ export default withEnglishFallback({
filterBuilderSearchColumns: "컬럼 검색...",
filterBuilderNoMatchingColumns: "일치하는 컬럼이 없습니다",
filterBuilderValue: "값",
filterBuilderValueTypeAuto: "자동",
filterBuilderValueTypeString: "문자열",
filterBuilderValueTypeNumber: "숫자",
filterBuilderValueTypeBoolean: "불리언",
filterBuilderValueTypeObjectId: "ObjectId",
filterBuilderValueTypeDate: "날짜",
filterBuilderValueTypeInt32: "Int32",
filterBuilderValueTypeInt64: "Int64",
filterBuilderValueTypeDecimal128: "Decimal128",
filterBuilderValueTypeJson: "JSON",
filterBuilderValueShortcutHint: "Shift+Enter로 조건을 추가할 수 있습니다",
filterBuilderValues: "값 (쉼표 또는 줄바꿈으로 구분)",
filterBuilderRangeStart: "시작 값",
@ -4831,8 +4841,8 @@ export default withEnglishFallback({
insertSpaceAfterCompletionDescription: "다음 문자가 허용할 때 키워드, 테이블 또는 컬럼 완성을 수락한 후 공백을 추가합니다",
sqlSemanticDiagnosticsEnabled: "SQL 의미 진단",
sqlSemanticDiagnosticsEnabledDescription: "활성화하면 편집기가 알 수 없는 테이블과 컬럼 같은 의미 문제를 보고합니다. 대규모 SQL의 파싱과 메타데이터 검사를 줄이려면 비활성화하세요.",
confirmDangerousSqlExecution: "위험한 SQL 전에 확인",
confirmDangerousSqlExecutionDescription: "비활성화하면 ALTER, DROP, DELETE, TRUNCATE 등 위험한 SQL이 경고 대화상자 없이 실행됩니다.",
confirmDangerousSqlExecution: "위험한 작업 전에 확인",
confirmDangerousSqlExecutionDescription: "비활성화하면 위험한 SQL과 테블 행 또는 문서 삭제가 경고 대화상자 없이 실행됩니다.",
continueOnErrorOnBatch: "오류 시 계속",
continueOnErrorOnBatchDescription: "활성화하면 다중 구문 실행이 첫 번째 실패에서 중지하는 대신 오류 후에도 계속됩니다.",
confirmUnsavedSqlClose: "저장되지 않은 SQL 닫기 전 확인",

View File

@ -1196,6 +1196,16 @@ export default withEnglishFallback({
filterBuilderAddRule: "Adicionar regra",
filterBuilderColumn: "Coluna",
filterBuilderValue: "Valor",
filterBuilderValueTypeAuto: "Automático",
filterBuilderValueTypeString: "Texto",
filterBuilderValueTypeNumber: "Número",
filterBuilderValueTypeBoolean: "Booleano",
filterBuilderValueTypeObjectId: "ObjectId",
filterBuilderValueTypeDate: "Data",
filterBuilderValueTypeInt32: "Int32",
filterBuilderValueTypeInt64: "Int64",
filterBuilderValueTypeDecimal128: "Decimal128",
filterBuilderValueTypeJson: "JSON",
filterBuilderValueShortcutHint: "Shift+Enter pode adicionar uma regra",
filterBuilderValues: "Valores (separados por vírgulas ou quebras de linha)",
filterBuilderRangeStart: "Valor inicial",
@ -5040,8 +5050,8 @@ export default withEnglishFallback({
autoCloseBracketsDescription: "Inserir automaticamente parênteses e aspas de fechamento ao digitar os de abertura",
sqlSemanticDiagnosticsEnabled: "Diagnóstico semântico de SQL",
sqlSemanticDiagnosticsEnabledDescription: "Quando ativado, o editor relata problemas semânticos como tabelas e colunas desconhecidas. Desative para reduzir a análise e verificações de metadados em SQL grande.",
confirmDangerousSqlExecution: "Confirmar antes de SQL perigoso",
confirmDangerousSqlExecutionDescription: "Quando desativado, ALTER, DROP, DELETE, TRUNCATE e outros SQL perigosos são executados sem a caixa de diálogo de aviso.",
confirmDangerousSqlExecution: "Confirmar antes de operações perigosas",
confirmDangerousSqlExecutionDescription: "Quando desativado, SQL perigoso e exclusões de linhas ou documentos são executados sem a caixa de diálogo de aviso.",
continueOnErrorOnBatch: "Continuar em erro",
continueOnErrorOnBatchDescription: "Quando ativado, a execução de múltiplas instruções continua após um erro em vez de parar na primeira falha.",
confirmUnsavedSqlClose: "Confirmar antes de fechar SQL não salvo",

View File

@ -1304,6 +1304,16 @@ export default withEnglishFallback({
filterBuilderSearchColumns: "搜索字段...",
filterBuilderNoMatchingColumns: "没有匹配的字段",
filterBuilderValue: "值",
filterBuilderValueTypeAuto: "自动",
filterBuilderValueTypeString: "字符串",
filterBuilderValueTypeNumber: "数字",
filterBuilderValueTypeBoolean: "布尔值",
filterBuilderValueTypeObjectId: "ObjectId",
filterBuilderValueTypeDate: "日期",
filterBuilderValueTypeInt32: "Int32",
filterBuilderValueTypeInt64: "Int64",
filterBuilderValueTypeDecimal128: "Decimal128",
filterBuilderValueTypeJson: "JSON",
filterBuilderValueShortcutHint: "Shift+Enter 可新增条件",
filterBuilderValues: "多个值(用逗号或换行分隔)",
filterBuilderRangeStart: "起始值",
@ -5305,8 +5315,8 @@ export default withEnglishFallback({
insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格",
sqlSemanticDiagnosticsEnabled: "SQL 语义诊断",
sqlSemanticDiagnosticsEnabledDescription: "开启后,编辑器会提示未知表、字段等语义问题;关闭可减少 SQL 解析和元数据检查的性能开销。",
confirmDangerousSqlExecution: "执行危险 SQL 前弹出确认",
confirmDangerousSqlExecutionDescription: "关闭后,ALTER、DROP、DELETE、TRUNCATE 等危险 SQL 将直接执行。",
confirmDangerousSqlExecution: "执行危险操作前弹出确认",
confirmDangerousSqlExecutionDescription: "关闭后,危险 SQL 以及表格行或文档删除将不再弹出确认。",
continueOnErrorOnBatch: "批量执行遇错继续",
continueOnErrorOnBatchDescription: "开启后,多条 SQL 批量执行时遇到错误将继续执行后续语句,而非中断。",
confirmUnsavedSqlClose: "关闭未保存 SQL 前弹出确认",

View File

@ -1195,6 +1195,16 @@ export default withEnglishFallback({
filterBuilderAddRule: "新增條件",
filterBuilderColumn: "欄位",
filterBuilderValue: "值",
filterBuilderValueTypeAuto: "自動",
filterBuilderValueTypeString: "字串",
filterBuilderValueTypeNumber: "數字",
filterBuilderValueTypeBoolean: "布林值",
filterBuilderValueTypeObjectId: "ObjectId",
filterBuilderValueTypeDate: "日期",
filterBuilderValueTypeInt32: "Int32",
filterBuilderValueTypeInt64: "Int64",
filterBuilderValueTypeDecimal128: "Decimal128",
filterBuilderValueTypeJson: "JSON",
filterBuilderValueShortcutHint: "Shift+Enter 可新增條件",
filterBuilderValues: "多個值(以逗號或換行分隔)",
filterBuilderRangeStart: "起始值",
@ -4484,8 +4494,8 @@ export default withEnglishFallback({
autoCloseBracketsDescription: "輸入左括號或左引號時自動補全對應的右括號或右引號",
sqlSemanticDiagnosticsEnabled: "SQL 語意診斷",
sqlSemanticDiagnosticsEnabledDescription: "開啟後,編輯器會提示未知資料表、欄位等語意問題;關閉可減少 SQL 解析和中繼資料檢查的效能負擔。",
confirmDangerousSqlExecution: "執行危險 SQL 前彈出確認",
confirmDangerousSqlExecutionDescription: "關閉後,ALTER、DROP、DELETE、TRUNCATE 等危險 SQL 會直接執行。",
confirmDangerousSqlExecution: "執行危險操作前彈出確認",
confirmDangerousSqlExecutionDescription: "關閉後,危險 SQL 以及表格列或文件刪除將不再彈出確認。",
continueOnErrorOnBatch: "批次執行遇錯繼續",
continueOnErrorOnBatchDescription: "開啟後,多條 SQL 批次執行時遇到錯誤將繼續執行後續語句,而非中斷。",
confirmUnsavedSqlClose: "關閉未儲存 SQL 前彈出確認",

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildDocumentFilterCondition, documentFilterModeOptions } from "@/lib/app/documentStoreProvider";
import { buildDocumentFilterCondition, documentFieldPathTreeFromDocuments, documentFilterModeOptions, documentFilterValueTypeOptions } from "@/lib/app/documentStoreProvider";
describe("document store structured filters", () => {
it("offers and builds inclusive comparison filters", () => {
@ -7,4 +7,35 @@ describe("document store structured filters", () => {
expect(buildDocumentFilterCondition({ id: "gte", fieldName: "score", mode: "greater-than-or-equal", rawValue: "80", conjunction: "AND" })).toEqual({ score: { $gte: 80 } });
expect(buildDocumentFilterCondition({ id: "lte", fieldName: "score", mode: "less-than-or-equal", rawValue: "80", conjunction: "AND" })).toEqual({ score: { $lte: 80 } });
});
it("infers MongoDB primitive and BSON types from field samples", () => {
const rule = { id: "id", fieldName: "_id", mode: "equals" as const, rawValue: "1", conjunction: "AND" as const };
expect(buildDocumentFilterCondition(rule, { kind: "mongodb", sampleValue: "001" })).toEqual({ _id: "1" });
expect(buildDocumentFilterCondition(rule, { kind: "mongodb", sampleValue: 1 })).toEqual({ _id: 1 });
expect(buildDocumentFilterCondition({ ...rule, rawValue: "true" }, { kind: "mongodb", sampleValue: false })).toEqual({ _id: true });
expect(buildDocumentFilterCondition({ ...rule, rawValue: "507f1f77bcf86cd799439011" }, { kind: "mongodb", sampleValue: { $oid: "507f191e810c19729de860ea" } })).toEqual({
_id: { $oid: "507f1f77bcf86cd799439011" },
});
});
it("lets MongoDB filters override the inferred value type", () => {
const baseRule = { id: "id", fieldName: "_id", mode: "equals" as const, rawValue: "1", conjunction: "AND" as const };
expect(documentFilterValueTypeOptions.map((option) => option.value)).toEqual(["auto", "string", "number", "boolean", "object-id", "date", "int32", "int64", "decimal128", "json"]);
expect(buildDocumentFilterCondition({ ...baseRule, valueType: "string" }, { kind: "mongodb", sampleValue: 1 })).toEqual({ _id: "1" });
expect(buildDocumentFilterCondition({ ...baseRule, valueType: "number" }, { kind: "mongodb", sampleValue: "1" })).toEqual({ _id: 1 });
expect(buildDocumentFilterCondition({ ...baseRule, rawValue: "2147483647", valueType: "int32" }, { kind: "mongodb" })).toEqual({ _id: { $numberInt: "2147483647" } });
expect(buildDocumentFilterCondition({ ...baseRule, rawValue: "9223372036854775807", valueType: "int64" }, { kind: "mongodb" })).toEqual({ _id: { $numberLong: "9223372036854775807" } });
expect(buildDocumentFilterCondition({ ...baseRule, rawValue: "12.50", valueType: "decimal128" }, { kind: "mongodb" })).toEqual({ _id: { $numberDecimal: "12.50" } });
expect(buildDocumentFilterCondition({ ...baseRule, rawValue: "2026-08-07T00:00:00.000Z", valueType: "date" }, { kind: "mongodb" })).toEqual({ _id: { $date: "2026-08-07T00:00:00.000Z" } });
expect(buildDocumentFilterCondition({ ...baseRule, rawValue: '{"status":"ok"}', valueType: "json" }, { kind: "mongodb" })).toEqual({ _id: { status: "ok" } });
expect(() => buildDocumentFilterCondition({ ...baseRule, rawValue: "not-a-number", valueType: "number" }, { kind: "mongodb" })).toThrow("Invalid MongoDB number filter value");
});
it("keeps the MongoDB _id sample for automatic type inference", () => {
const tree = documentFieldPathTreeFromDocuments([{ _id: "001", name: "Alice" }]);
expect(tree[0]).toMatchObject({ path: "_id", sampleValue: "001" });
});
});

View File

@ -6,6 +6,7 @@ import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
export type DocumentStoreKind = "mongodb" | "elasticsearch";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "is-null" | "is-not-null";
export type DocumentFilterValueType = "auto" | "string" | "number" | "boolean" | "object-id" | "date" | "int32" | "int64" | "decimal128" | "json";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
export type ElasticsearchQueryType = "term" | "terms" | "match" | "match_phrase" | "wildcard" | "range_gt" | "range_gte" | "range_lt" | "range_lte" | "exists";
@ -15,6 +16,7 @@ export type DocumentFilterRule = {
mode: DocumentFilterMode;
rawValue: string;
conjunction: "AND" | "OR";
valueType?: DocumentFilterValueType;
elasticsearchClause?: ElasticsearchBoolClause;
elasticsearchQueryType?: ElasticsearchQueryType;
};
@ -62,6 +64,19 @@ export const documentFilterModeOptions: Array<{ value: DocumentFilterMode; label
{ value: "is-not-null", labelKey: "grid.filterBuilderIsNotNull" },
];
export const documentFilterValueTypeOptions: Array<{ value: DocumentFilterValueType; labelKey: string }> = [
{ value: "auto", labelKey: "grid.filterBuilderValueTypeAuto" },
{ value: "string", labelKey: "grid.filterBuilderValueTypeString" },
{ value: "number", labelKey: "grid.filterBuilderValueTypeNumber" },
{ value: "boolean", labelKey: "grid.filterBuilderValueTypeBoolean" },
{ value: "object-id", labelKey: "grid.filterBuilderValueTypeObjectId" },
{ value: "date", labelKey: "grid.filterBuilderValueTypeDate" },
{ value: "int32", labelKey: "grid.filterBuilderValueTypeInt32" },
{ value: "int64", labelKey: "grid.filterBuilderValueTypeInt64" },
{ value: "decimal128", labelKey: "grid.filterBuilderValueTypeDecimal128" },
{ value: "json", labelKey: "grid.filterBuilderValueTypeJson" },
];
export const elasticsearchBoolClauseOptions: ElasticsearchBoolClause[] = ["filter", "must", "should", "must_not"];
const ELASTICSEARCH_TEXT_QUERY_TYPES: ElasticsearchQueryType[] = ["match", "match_phrase", "term", "wildcard", "exists"];
@ -126,6 +141,7 @@ export function defaultDocumentFilterRule(id: string, fieldName = ""): DocumentF
mode: "equals",
rawValue: "",
conjunction: "AND",
valueType: "auto",
elasticsearchClause: "filter",
elasticsearchQueryType: "term",
};
@ -221,9 +237,10 @@ export function documentFieldPathTreeFromDocuments(documents: readonly Record<st
if (documents.length === 0) return [];
const rootNodes: DocumentFieldPathAccumulatorNode[] = [];
const rootByKey = new Map<string, DocumentFieldPathAccumulatorNode>();
ensureDocumentFieldPathNode(rootNodes, rootByKey, "_id", "_id", "scalar");
const idNode = ensureDocumentFieldPathNode(rootNodes, rootByKey, "_id", "_id", "scalar");
for (const doc of documents) {
if (idNode.sampleValue === undefined && doc._id !== undefined) idNode.sampleValue = doc._id;
for (const [key, value] of Object.entries(doc)) {
if (key === "_id") continue;
collectDocumentFieldPathNode(rootNodes, rootByKey, key, value);
@ -419,12 +436,13 @@ export function elasticsearchStructuredFilter(query: Record<string, unknown> | n
type DocumentFilterParseOptions = {
kind?: DocumentStoreKind;
sampleValue?: unknown;
valueType?: DocumentFilterValueType;
};
export function buildDocumentFilterCondition(rule: DocumentFilterRule, options: DocumentFilterParseOptions = {}): Record<string, unknown> | null {
if (!rule.fieldName) return null;
if (documentFilterModeNeedsValue(rule.mode) && !rule.rawValue.trim()) return null;
const value = documentFilterModeNeedsValue(rule.mode) ? parseDocumentFilterValue(rule.rawValue, options) : null;
const value = documentFilterModeNeedsValue(rule.mode) ? parseDocumentFilterValue(rule.rawValue, { ...options, valueType: rule.valueType }) : null;
const textValue = documentFilterModeNeedsValue(rule.mode) ? String(parseDocumentFilterValue(rule.rawValue)) : "";
switch (rule.mode) {
case "equals":
@ -647,41 +665,93 @@ export function formatDocumentQueryInput(input: string, kind?: DocumentStoreKind
function parseDocumentFilterValue(raw: string, options: DocumentFilterParseOptions = {}): unknown {
const trimmed = raw.trim();
if (!trimmed) return "";
if (options.kind === "mongodb") {
const valueType = options.valueType ?? "auto";
if (valueType !== "auto") return parseMongoFilterValueAs(trimmed, valueType, options);
const inferredType = inferMongoFilterValueType(options.sampleValue);
if (inferredType) return parseMongoFilterValueAs(trimmed, inferredType, options);
}
try {
return parseJsonPreservingLargeIntegers(trimmed, options);
} catch {
return mongoTypedFilterValue(trimmed, options);
return trimmed;
}
}
function mongoTypedFilterValue(raw: string, options: DocumentFilterParseOptions): unknown {
if (options.kind !== "mongodb") return raw;
const sampleValue = mongoTypedFilterSample(options.sampleValue);
if (!sampleValue) return raw;
if (typeof sampleValue.$oid === "string") return { $oid: raw };
if ("$date" in sampleValue) return { $date: raw };
if (typeof sampleValue.$numberLong === "string" && /^-?\d+$/.test(raw)) return { $numberLong: raw };
return raw;
function parseMongoFilterValueAs(raw: string, valueType: Exclude<DocumentFilterValueType, "auto">, options: DocumentFilterParseOptions): unknown {
const text = unquoteMongoFilterString(raw);
switch (valueType) {
case "string":
return text;
case "number": {
const value = Number(text);
if (!Number.isFinite(value)) throw invalidMongoFilterValue(valueType, raw);
return value;
}
case "boolean":
if (text.toLowerCase() === "true") return true;
if (text.toLowerCase() === "false") return false;
throw invalidMongoFilterValue(valueType, raw);
case "object-id":
if (!/^[0-9a-f]{24}$/i.test(text)) throw invalidMongoFilterValue(valueType, raw);
return { $oid: text };
case "date":
if (/^-?\d+$/.test(text)) return { $date: { $numberLong: text } };
if (Number.isNaN(Date.parse(text))) throw invalidMongoFilterValue(valueType, raw);
return { $date: text };
case "int32": {
if (!/^-?\d+$/.test(text)) throw invalidMongoFilterValue(valueType, raw);
const value = BigInt(text);
if (value < -2147483648n || value > 2147483647n) throw invalidMongoFilterValue(valueType, raw);
return { $numberInt: text };
}
case "int64": {
if (!/^-?\d+$/.test(text)) throw invalidMongoFilterValue(valueType, raw);
const value = BigInt(text);
if (value < MIN_BSON_INT64 || value > MAX_BSON_INT64) throw invalidMongoFilterValue(valueType, raw);
return { $numberLong: text };
}
case "decimal128":
if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(text)) throw invalidMongoFilterValue(valueType, raw);
return { $numberDecimal: text };
case "json":
try {
return parseJsonPreservingLargeIntegers(raw, options);
} catch {
throw invalidMongoFilterValue(valueType, raw);
}
}
}
function mongoTypedFilterSample(sampleValue: unknown): Record<string, unknown> | null {
if (isPlainRecord(sampleValue)) return sampleValue;
if (!Array.isArray(sampleValue)) return null;
const samples = sampleValue.filter((value) => value !== null && value !== undefined);
if (!samples.length || samples.some((value) => !isPlainRecord(value))) return null;
const records = samples as Record<string, unknown>[];
const sampleKind = mongoTypedFilterSampleKind(records[0]);
// Mixed arrays are ambiguous, so infer a BSON type only from homogeneous scalar wrappers.
if (!sampleKind || records.some((value) => mongoTypedFilterSampleKind(value) !== sampleKind)) return null;
return records[0];
function unquoteMongoFilterString(raw: string): string {
try {
const parsed = JSON.parse(raw);
return typeof parsed === "string" ? parsed : raw;
} catch {
return raw;
}
}
function mongoTypedFilterSampleKind(sampleValue: Record<string, unknown>): "$oid" | "$date" | "$numberLong" | null {
if (typeof sampleValue.$oid === "string") return "$oid";
if ("$date" in sampleValue) return "$date";
if (typeof sampleValue.$numberLong === "string") return "$numberLong";
return null;
function invalidMongoFilterValue(valueType: DocumentFilterValueType, raw: string): Error {
return new Error(`Invalid MongoDB ${valueType} filter value: ${raw}`);
}
function inferMongoFilterValueType(sampleValue: unknown): Exclude<DocumentFilterValueType, "auto"> | null {
if (typeof sampleValue === "string") return "string";
if (typeof sampleValue === "number") return "number";
if (typeof sampleValue === "boolean") return "boolean";
if (Array.isArray(sampleValue)) {
const inferred = sampleValue.map(inferMongoFilterValueType).filter((value): value is Exclude<DocumentFilterValueType, "auto"> => !!value);
return inferred.length > 0 && inferred.every((value) => value === inferred[0]) ? inferred[0] : null;
}
if (!isPlainRecord(sampleValue)) return null;
if (typeof sampleValue.$oid === "string") return "object-id";
if ("$date" in sampleValue) return "date";
if (typeof sampleValue.$numberInt === "string") return "int32";
if (typeof sampleValue.$numberLong === "string") return "int64";
if (typeof sampleValue.$numberDecimal === "string") return "decimal128";
if (typeof sampleValue.$numberDouble === "string") return "number";
return "json";
}
export function elasticsearchSearchBodyFromDocumentQuery(options: Pick<DocumentStoreQueryPreviewOptions, "filterJson" | "sortJson" | "skip" | "limit">): Record<string, unknown> {

View File

@ -8,6 +8,37 @@ const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
const MIN_BSON_INT64 = -9223372036854775808n;
const MAX_BSON_INT64 = 9223372036854775807n;
const MONGO_EXTENDED_JSON_VALUE_KEYS = new Set(["$binary", "$code", "$date", "$dbPointer", "$maxKey", "$minKey", "$numberDecimal", "$numberDouble", "$numberInt", "$numberLong", "$oid", "$regularExpression", "$symbol", "$timestamp", "$undefined", "$uuid"]);
const MONGO_EXTENDED_JSON_NUMERIC_TYPES = new Map([
["$numberInt", "int32"],
["$numberLong", "int64"],
["$numberDouble", "double"],
["$numberDecimal", "decimal128"],
] as const);
function mongoDocumentNumericValueType(value: unknown): string | undefined {
if (typeof value === "number") return "number";
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const object = value as Record<string, unknown>;
const keys = Object.keys(object);
if (keys.length !== 1) return undefined;
const key = keys[0] as "$numberInt" | "$numberLong" | "$numberDouble" | "$numberDecimal";
return typeof object[key] === "string" ? MONGO_EXTENDED_JSON_NUMERIC_TYPES.get(key) : undefined;
}
export function mongoDocumentGridColumnTypes(documents: readonly Record<string, unknown>[], columns: readonly string[]): string[] {
return columns.map((column) => {
let inferredType: string | undefined;
for (const document of documents) {
const value = document[column];
if (value === undefined || value === null) continue;
const numericType = mongoDocumentNumericValueType(value);
if (!numericType) return "";
inferredType = inferredType && inferredType !== numericType ? "number" : numericType;
}
return inferredType ?? "";
});
}
export function mongoShellDateToExtendedJson(value: unknown): unknown {
if (typeof value !== "string") return value;

View File

@ -9,11 +9,43 @@ import {
buildMongoUpdateDocument,
formatMongoShellLiteral,
mongoDocumentDisplayValue,
mongoDocumentGridColumnTypes,
mongoDocumentIdForGrid,
parseMongoDocumentInputValue,
serializeMongoDocumentId,
} from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts";
test("infers only consistently numeric Mongo grid columns", () => {
const documents = [
{
native: 1,
int32: { $numberInt: "1" },
int64: { $numberLong: "9007199254740993" },
double: { $numberDouble: "1.5" },
decimal: { $numberDecimal: "12.50" },
mixedNumeric: 1,
numericString: "123",
mixed: 1,
empty: null,
},
{
native: 2.5,
int32: { $numberInt: "2" },
int64: { $numberLong: "9007199254740994" },
double: { $numberDouble: "2.5" },
decimal: { $numberDecimal: "13.50" },
mixedNumeric: { $numberLong: "2" },
numericString: "456",
mixed: "2",
},
];
assert.deepEqual(
mongoDocumentGridColumnTypes(documents, ["native", "int32", "int64", "double", "decimal", "mixedNumeric", "numericString", "mixed", "empty", "missing"]),
["number", "int32", "int64", "double", "decimal128", "number", "", "", "", ""],
);
});
test("parses Mongo shell ISODate literals as extended JSON dates", () => {
assert.deepEqual(parseMongoDocumentInputValue('ISODate("2026-06-10T13:59:31.287Z")'), {
$date: "2026-06-10T13:59:31.287Z",