feat(mongodb): improve sidebar index management
This commit is contained in:
1 parent
22055e081f
commit
8e7b74d0ee
56 files changed
+3558
-246
No files matched your search
@@ -38,6 +38,7 @@ import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/z
|
||||
import { isLocalFileTypeDb } from "@/lib/connection/connectionFile";
|
||||
import { MQ_PINNED_VERSION_OPTIONS, pinnedVersionToSelection, selectionToPinnedVersion } from "@/lib/mq/mqPinnedVersionOptions";
|
||||
import { mongodbAuthFailureHint, mongoUrlParam, mongoUrlParamIsTrue, normalizeMongoTlsFormState, setMongoUrlParam, setMongoUrlParamBoolean } from "@/lib/mongo/mongoConnectionOptions";
|
||||
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
|
||||
import { mysqlCleartextPasswordAuthEnabled, setMysqlCleartextPasswordAuthEnabled } from "@/lib/database/mysqlConnectionOptions";
|
||||
import { applyDamengSslUrlParams, damengSslFormConfig } from "@/lib/database/damengSslOptions";
|
||||
import { DamengJvmSystemPropertyError, damengJvmSystemPropertiesText, parseDamengJvmSystemProperties } from "@/lib/database/damengJvmOptions";
|
||||
@@ -2884,7 +2885,7 @@ const mongoRetryWrites = computed({
|
||||
},
|
||||
});
|
||||
const mongoDriverMode = computed({
|
||||
get: () => (form.value.driver_profile === "mongodb-legacy" ? "legacy" : "auto"),
|
||||
get: () => (isMongoLegacyDriverProfile(form.value.driver_profile) ? "legacy" : "auto"),
|
||||
set: (value: string) => {
|
||||
form.value.driver_profile = value === "legacy" ? "mongodb-legacy" : "mongodb";
|
||||
form.value.driver_label = value === "legacy" ? "MongoDB (Legacy)" : "MongoDB";
|
||||
@@ -3276,9 +3277,14 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
|
||||
} else if (config.db_type === "mongodb") {
|
||||
config.connection_string = normalizeMongoConnectionString(config.connection_string?.trim() || "");
|
||||
}
|
||||
if (config.db_type === "mongodb" && config.driver_profile !== "mongodb-legacy") {
|
||||
config.driver_profile = "mongodb";
|
||||
config.driver_label = "MongoDB";
|
||||
if (config.db_type === "mongodb") {
|
||||
if (isMongoLegacyDriverProfile(config.driver_profile)) {
|
||||
config.driver_profile = "mongodb-legacy";
|
||||
config.driver_label = "MongoDB (Legacy)";
|
||||
} else {
|
||||
config.driver_profile = "mongodb";
|
||||
config.driver_label = "MongoDB";
|
||||
}
|
||||
}
|
||||
if (config.db_type === "mongodb") {
|
||||
const mongoTls = normalizeMongoTlsFormState(!!config.ssl, config.url_params, config.ca_cert_path);
|
||||
|
||||
@@ -71,7 +71,7 @@ import { documentGridColumnVisibilityScopeKey, migrateDocumentGridColumnVisibili
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
import type { EditNode } from "@/types/editor";
|
||||
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
|
||||
import type { ColumnInfo, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
@@ -85,6 +85,7 @@ const props = defineProps<{
|
||||
database: string;
|
||||
collection: string;
|
||||
databaseType?: DatabaseType;
|
||||
tableMeta?: NonNullable<QueryTab["tableMeta"]>;
|
||||
}>();
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -1667,6 +1668,7 @@ defineExpose({ focusSearch });
|
||||
:result="gridResult"
|
||||
:connection-id="props.connectionId"
|
||||
:database="props.database"
|
||||
:table-meta="props.tableMeta"
|
||||
:column-layout-scope-key="documentColumnLayoutScopeKey"
|
||||
context="results"
|
||||
:database-type="props.databaseType"
|
||||
|
||||
@@ -221,6 +221,10 @@ import { supportsTableStructureEditing } from "@/lib/database/databaseCapabiliti
|
||||
import { rememberDataGridConditionHistory } from "@/lib/dataGrid/dataGridConditionHistory";
|
||||
import { restoreDataGridLocalColumnFilters, serializeDataGridLocalColumnFilters } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { mongoCollectionSupportsIndexes, supportsMongoIndexMutations } from "@/lib/mongo/mongoCapabilities";
|
||||
import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
|
||||
import { isProtectedMongoIndex, mongoDropAllIndexesPreview, mongoDropIndexFailureCount, mongoDropIndexPreview } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { runMongoMutation } from "@/lib/sidebar/runMongoSidebarMutation";
|
||||
import { dataGridConditionColumnOptions, dataGridConditionIdentifierQuote } from "@/lib/dataGrid/dataGridConditionCompletion";
|
||||
import { isMacOS } from "@/lib/backend/platform";
|
||||
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
@@ -7336,7 +7340,8 @@ function toggleCellDetailPanelLayout() {
|
||||
const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(props.databaseType));
|
||||
const canOpenTableStructureEditor = computed(() => !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsTableStructureEditing(resolvedDatabaseType.value));
|
||||
const mongoConnectionConfig = resolvedConnectionConfig;
|
||||
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && mongoConnectionConfig.value?.db_type === "mongodb" && mongoConnectionConfig.value?.driver_profile !== "mongodb-legacy");
|
||||
const canManageMongoIndexes = computed(() => resolvedDatabaseType.value === "mongodb" && !!props.connectionId && !!props.database && !!props.tableMeta?.tableName && supportsMongoIndexMutations(mongoConnectionConfig.value, props.tableMeta?.tableType));
|
||||
const canShowTableIndexes = computed(() => tableMetadataCapabilities.value.indexes && (resolvedDatabaseType.value !== "mongodb" || mongoCollectionSupportsIndexes(props.tableMeta?.tableType)));
|
||||
const tableInfoTabs = computed(() => {
|
||||
const tabs: TableInfoTabItem[] = [];
|
||||
if (tableMetadataCapabilities.value.ddl) {
|
||||
@@ -7350,7 +7355,7 @@ const tableInfoTabs = computed(() => {
|
||||
count: props.tableMeta?.columns.length,
|
||||
});
|
||||
}
|
||||
if (tableMetadataCapabilities.value.indexes) {
|
||||
if (canShowTableIndexes.value) {
|
||||
tabs.push({ id: "indexes", label: t("grid.tableInfoIndexes"), icon: KeyRound, count: indexes.value.length });
|
||||
}
|
||||
if (tableMetadataCapabilities.value.foreignKeys) {
|
||||
@@ -7435,6 +7440,19 @@ async function reloadIndexes() {
|
||||
await fetchIndexes();
|
||||
}
|
||||
|
||||
async function refreshMongoIndexMetadataAfterMutation() {
|
||||
await reloadIndexes();
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
const collection = props.tableMeta?.tableName;
|
||||
if (!connectionId || !database || !collection) return;
|
||||
try {
|
||||
await refreshLoadedMongoIndexes(connectionStore, { connectionId, database, collection });
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.mongoIndexRefreshFailed", { message: String(e?.message || e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchForeignKeys() {
|
||||
const requestIdentity = currentForeignKeyTableIdentity.value;
|
||||
if (!props.connectionId || !props.tableMeta || !requestIdentity || foreignKeysLoaded.value || foreignKeysLoading.value) return;
|
||||
@@ -7775,7 +7793,8 @@ const filteredIndexes = computed(() => {
|
||||
return indexes.value.filter((i) => i.name.toLowerCase().includes(q) || i.columns.some((c) => c.toLowerCase().includes(q)));
|
||||
});
|
||||
|
||||
const droppableMongoIndexes = computed(() => indexes.value.filter((index) => !index.is_primary));
|
||||
const droppableMongoIndexes = computed(() => indexes.value.filter((index) => !isProtectedMongoIndex(index)));
|
||||
|
||||
const dropMongoIndexConfirmMessage = computed(() =>
|
||||
pendingDropMongoIndex.value
|
||||
? t("contextMenu.confirmDropMongoIndexMessage", {
|
||||
@@ -7784,51 +7803,86 @@ const dropMongoIndexConfirmMessage = computed(() =>
|
||||
})
|
||||
: "",
|
||||
);
|
||||
const dropMongoIndexPreview = computed(() => (pendingDropMongoIndex.value ? mongoDropIndexPreview(props.database || "", props.tableMeta?.tableName || "", pendingDropMongoIndex.value.name) : ""));
|
||||
const dropAllMongoIndexesConfirmMessage = computed(() => t("contextMenu.confirmDropMongoAllIndexesMessage", { name: props.tableMeta?.tableName || "" }));
|
||||
const dropAllMongoIndexesConfirmDetails = computed(() => t("contextMenu.confirmDropMongoAllIndexesDetails"));
|
||||
const dropMongoIndexPreview = computed(() => (pendingDropMongoIndex.value ? `db.getCollection(${JSON.stringify(props.tableMeta?.tableName || "")}).dropIndex(${JSON.stringify(pendingDropMongoIndex.value.name)})` : ""));
|
||||
const dropAllMongoIndexesPreview = computed(() => `db.getCollection(${JSON.stringify(props.tableMeta?.tableName || "")}).dropIndexes()`);
|
||||
const dropAllMongoIndexesPreview = computed(() => mongoDropAllIndexesPreview(props.database || "", props.tableMeta?.tableName || ""));
|
||||
|
||||
function requestDropMongoIndex(index: IndexInfo) {
|
||||
if (!canManageMongoIndexes.value || isProtectedMongoIndex(index)) return;
|
||||
pendingDropMongoIndex.value = index;
|
||||
showDropMongoIndexConfirm.value = true;
|
||||
}
|
||||
|
||||
function requestDropAllMongoIndexes() {
|
||||
if (!canManageMongoIndexes.value || droppableMongoIndexes.value.length === 0) return;
|
||||
showDropAllMongoIndexesConfirm.value = true;
|
||||
}
|
||||
|
||||
async function confirmDropMongoIndex() {
|
||||
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || !pendingDropMongoIndex.value || dropMongoIndexLoading.value) return;
|
||||
dropMongoIndexLoading.value = true;
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
await api.mongoDropIndexes(props.connectionId, props.database, props.tableMeta.tableName, JSON.stringify(pendingDropMongoIndex.value.name), true);
|
||||
toast(t("contextMenu.dropTableChildObjectSuccess", { name: pendingDropMongoIndex.value.name }), 3000);
|
||||
showDropMongoIndexConfirm.value = false;
|
||||
pendingDropMongoIndex.value = null;
|
||||
await reloadIndexes();
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
dropMongoIndexLoading.value = false;
|
||||
}
|
||||
const index = pendingDropMongoIndex.value;
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
const tableName = props.tableMeta?.tableName;
|
||||
if (!connectionId || !database || !tableName || !index || !canManageMongoIndexes.value || isProtectedMongoIndex(index) || dropMongoIndexLoading.value) return;
|
||||
await runMongoMutation({
|
||||
connection: connectionStore.getConfig(connectionId),
|
||||
database,
|
||||
reviewText: dropMongoIndexPreview.value,
|
||||
source: t("production.sourceDataGrid"),
|
||||
loading: dropMongoIndexLoading,
|
||||
beforeExecute: () => connectionStore.ensureConnected(connectionId),
|
||||
execute: async () => {
|
||||
try {
|
||||
return await api.mongoDropIndexes(connectionId, database, tableName, JSON.stringify(index.name), true);
|
||||
} finally {
|
||||
await refreshMongoIndexMetadataAfterMutation();
|
||||
}
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
const failed = mongoDropIndexFailureCount(result);
|
||||
if (failed > 0) {
|
||||
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
|
||||
} else {
|
||||
toast(t("contextMenu.dropTableChildObjectSuccess", { name: index.name }), 3000);
|
||||
}
|
||||
showDropMongoIndexConfirm.value = false;
|
||||
pendingDropMongoIndex.value = null;
|
||||
},
|
||||
onError: (e: any) => toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000),
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmDropAllMongoIndexes() {
|
||||
if (!props.connectionId || !props.database || !props.tableMeta?.tableName || dropAllMongoIndexesLoading.value) return;
|
||||
dropAllMongoIndexesLoading.value = true;
|
||||
try {
|
||||
await connectionStore.ensureConnected(props.connectionId);
|
||||
const result = await api.mongoDropIndexes(props.connectionId, props.database, props.tableMeta.tableName, undefined, false);
|
||||
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: props.tableMeta.tableName }), 3000);
|
||||
showDropAllMongoIndexesConfirm.value = false;
|
||||
await reloadIndexes();
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
dropAllMongoIndexesLoading.value = false;
|
||||
}
|
||||
const connectionId = props.connectionId;
|
||||
const database = props.database;
|
||||
const tableName = props.tableMeta?.tableName;
|
||||
if (!connectionId || !database || !tableName || !canManageMongoIndexes.value || dropAllMongoIndexesLoading.value) return;
|
||||
await runMongoMutation({
|
||||
connection: connectionStore.getConfig(connectionId),
|
||||
database,
|
||||
reviewText: dropAllMongoIndexesPreview.value,
|
||||
source: t("production.sourceDataGrid"),
|
||||
loading: dropAllMongoIndexesLoading,
|
||||
beforeExecute: () => connectionStore.ensureConnected(connectionId),
|
||||
execute: async () => {
|
||||
try {
|
||||
return await api.mongoDropIndexes(connectionId, database, tableName, undefined, false);
|
||||
} finally {
|
||||
await refreshMongoIndexMetadataAfterMutation();
|
||||
}
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
const failed = mongoDropIndexFailureCount(result);
|
||||
if (failed > 0) {
|
||||
toast(t("contextMenu.dropIndexesPartialFailure", { success: result.dropped_names.length, failed }), 5000);
|
||||
} else {
|
||||
toast(t("contextMenu.dropAllIndexesSuccess", { count: result.dropped_names.length, name: tableName }), 3000);
|
||||
}
|
||||
showDropAllMongoIndexesConfirm.value = false;
|
||||
},
|
||||
onError: (e: any) => toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000),
|
||||
});
|
||||
}
|
||||
|
||||
const filteredForeignKeys = computed(() => {
|
||||
@@ -9373,7 +9427,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
{{ index.columns.join(", ") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="canManageMongoIndexes && !index.is_primary" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-[11px] text-destructive hover:text-destructive" @click="requestDropMongoIndex(index)">
|
||||
<Button v-if="canManageMongoIndexes && !isProtectedMongoIndex(index)" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-[11px] text-destructive hover:text-destructive" @click="requestDropMongoIndex(index)">
|
||||
<Trash2 class="mr-1 h-3 w-3" />
|
||||
{{ t("contextMenu.dropIndex") }}
|
||||
</Button>
|
||||
|
||||
@@ -1843,7 +1843,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||||
<template v-else-if="activeTab.mode === 'mongo'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
|
||||
<DocumentBrowser ref="documentBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { toRefs, watch } from "vue";
|
||||
import { AlertTriangle, Check, Loader2, Clipboard, Upload } from "@lucide/vue";
|
||||
import { computed, toRefs, watch } from "vue";
|
||||
import { AlertTriangle, Check, Loader2, Clipboard, Plus, Trash2, Upload } from "@lucide/vue";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const props = defineProps<{ controller: Record<string, any> }>();
|
||||
const emit = defineEmits<{ closed: [] }>();
|
||||
@@ -94,6 +96,17 @@ const {
|
||||
renameMongoCollectionPreview,
|
||||
renameMongoCollectionLoading,
|
||||
confirmRenameMongoCollection,
|
||||
showCreateMongoIndexDialog,
|
||||
mongoCreateIndexForm,
|
||||
mongoCreateIndexFieldOptions,
|
||||
mongoCreateIndexError,
|
||||
mongoCreateIndexLoading,
|
||||
mongoIndexKeyTypes,
|
||||
mongoCreateIndexCanSubmit,
|
||||
mongoCreateIndexCanAddField,
|
||||
addMongoCreateIndexField,
|
||||
removeMongoCreateIndexField,
|
||||
confirmCreateMongoIndex,
|
||||
showRedisDatabaseAliasDialog,
|
||||
redisDatabaseAliasInput,
|
||||
redisDatabaseAliasSaving,
|
||||
@@ -139,6 +152,14 @@ function closeCreateDatabaseResult() {
|
||||
showCreateDatabasePreviewDialog.value = false;
|
||||
}
|
||||
|
||||
const mongoIndexCollectionName = computed(() => node.value.tableName || node.value.label);
|
||||
|
||||
function mongoIndexTypeLabel(type: string): string {
|
||||
if (type === "1") return t.value("contextMenu.mongoIndexAscending");
|
||||
if (type === "-1") return t.value("contextMenu.mongoIndexDescending");
|
||||
return type;
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
showDeleteConfirm,
|
||||
@@ -155,6 +176,7 @@ watch(
|
||||
showCreateNacosNamespaceDialog,
|
||||
showEditNacosNamespaceDialog,
|
||||
showRenameMongoCollectionDialog,
|
||||
showCreateMongoIndexDialog,
|
||||
showRedisDatabaseAliasDialog,
|
||||
showCreateSchemaDialog,
|
||||
showEditSchemaCommentDialog,
|
||||
@@ -255,6 +277,73 @@ watch(
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showCreateMongoIndexDialog">
|
||||
<DialogContent class="min-w-0 sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("contextMenu.createMongoIndexTitle", { collection: mongoIndexCollectionName }) }}</DialogTitle>
|
||||
<p class="font-mono text-xs text-muted-foreground">{{ node.database }} / {{ mongoIndexCollectionName }}</p>
|
||||
</DialogHeader>
|
||||
<div class="grid min-w-0 gap-5">
|
||||
<section class="grid min-w-0 gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm font-medium">{{ t("contextMenu.createMongoIndexFields") }}</span>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="mongoCreateIndexLoading || !mongoCreateIndexCanAddField" @click="addMongoCreateIndexField">
|
||||
<Plus class="mr-1 h-4 w-4" />
|
||||
{{ t("mongo.addField") }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex min-w-0 gap-2 px-0.5 text-xs text-muted-foreground">
|
||||
<span class="min-w-0 flex-1">{{ t("mongo.field") }}</span>
|
||||
<span class="w-36 shrink-0">{{ t("structureEditor.indexType") }}</span>
|
||||
<span class="w-8 shrink-0"></span>
|
||||
</div>
|
||||
<div v-for="field in mongoCreateIndexForm.fields" :key="field.id" class="flex min-w-0 items-center gap-2">
|
||||
<Input v-model="field.path" :list="`mongo-index-fields-${field.id}`" :disabled="mongoCreateIndexLoading" :placeholder="t('mongo.fieldPlaceholder')" :aria-label="t('mongo.field')" class="h-8 min-w-0 flex-1" autocomplete="off" />
|
||||
<datalist :id="`mongo-index-fields-${field.id}`">
|
||||
<option v-for="option in mongoCreateIndexFieldOptions" :key="option" :value="option"></option>
|
||||
</datalist>
|
||||
<Select v-model="field.type" :disabled="mongoCreateIndexLoading">
|
||||
<SelectTrigger class="h-8 w-36 shrink-0" :aria-label="t('structureEditor.indexType')">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="type in mongoIndexKeyTypes" :key="type" :value="type">{{ mongoIndexTypeLabel(type) }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-8 w-8" :disabled="mongoCreateIndexLoading || mongoCreateIndexForm.fields.length === 1" :title="t('structureEditor.remove')" :aria-label="t('structureEditor.remove')" @click="removeMongoCreateIndexField(field.id)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label class="grid gap-1.5 text-sm font-medium">
|
||||
{{ t("structureEditor.indexName") }}
|
||||
<Input v-model="mongoCreateIndexForm.name" :disabled="mongoCreateIndexLoading" :placeholder="t('contextMenu.createMongoIndexNamePlaceholder')" :aria-label="t('structureEditor.indexName')" />
|
||||
<span class="text-xs font-normal text-muted-foreground">{{ t("contextMenu.createMongoIndexNameHint") }}</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-2 rounded-md border p-3 sm:grid-cols-2">
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm font-medium">
|
||||
{{ t("structureEditor.unique") }}
|
||||
<Switch v-model="mongoCreateIndexForm.unique" :disabled="mongoCreateIndexLoading" :aria-label="t('structureEditor.unique')" />
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm font-medium">
|
||||
{{ t("contextMenu.createMongoIndexSparse") }}
|
||||
<Switch v-model="mongoCreateIndexForm.sparse" :disabled="mongoCreateIndexLoading" :aria-label="t('contextMenu.createMongoIndexSparse')" />
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="mongoCreateIndexError" class="min-w-0 max-w-full whitespace-pre-wrap break-all text-sm text-destructive">{{ mongoCreateIndexError }}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="mongoCreateIndexLoading" @click="showCreateMongoIndexDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="mongoCreateIndexLoading || !mongoCreateIndexCanSubmit" @click="confirmCreateMongoIndex">
|
||||
<Loader2 v-if="mongoCreateIndexLoading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("contextMenu.createMongoIndex") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showRedisDatabaseAliasDialog">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
isSingleDatabase,
|
||||
} from "@/lib/database/databaseCapabilities";
|
||||
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
|
||||
import { mongoCollectionTableTypeFromNode, mongoDropIndexFailureCount } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { handleSidebarTreeDeleteShortcut } from "@/lib/sidebar/sidebarTreeDeleteShortcut";
|
||||
@@ -148,7 +149,7 @@ import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/s
|
||||
import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
|
||||
import { flattenTree } from "@/composables/useFlatTree";
|
||||
import { createDatabaseCollationOptionsForCharset, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { executeWithProductionContextGuard, executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
|
||||
import { createSidebarActionTarget, findSidebarActionTarget, releaseRemovedSidebarActionTarget, type SidebarActionTarget } from "@/lib/sidebar/sidebarActionTarget";
|
||||
import { createSidebarMenuContext, normalizeSidebarMenuDescriptors } from "@/lib/sidebar/sidebarTreeMenuDescriptors";
|
||||
@@ -234,6 +235,11 @@ import {
|
||||
dropMongoIndexLoading,
|
||||
showDropAllMongoIndexesConfirm,
|
||||
dropAllMongoIndexesLoading,
|
||||
showCreateMongoIndexDialog,
|
||||
mongoCreateIndexForm,
|
||||
mongoCreateIndexFieldOptions,
|
||||
mongoCreateIndexError,
|
||||
mongoCreateIndexLoading,
|
||||
showFlushRedisDbConfirm,
|
||||
showCreateSchemaDialog,
|
||||
createSchemaName,
|
||||
@@ -392,9 +398,18 @@ const {
|
||||
mongoIndexNameForNode,
|
||||
canDropMongoIndexNode,
|
||||
canDropMongoIndex,
|
||||
mongoIndexDropPreview,
|
||||
canDropAllMongoIndexes,
|
||||
mongoIndexDropPreview,
|
||||
mongoDropAllIndexesPreview,
|
||||
refreshMongoIndexTreeAfterMutation,
|
||||
canCreateMongoIndex,
|
||||
mongoIndexKeyTypes,
|
||||
mongoCreateIndexCanSubmit,
|
||||
mongoCreateIndexCanAddField,
|
||||
prepareCreateMongoIndexDialog,
|
||||
addMongoCreateIndexField,
|
||||
removeMongoCreateIndexField,
|
||||
confirmCreateMongoIndex,
|
||||
openCreateNacosNamespaceDialog,
|
||||
confirmCreateNacosNamespace,
|
||||
openEditNacosNamespaceDialog,
|
||||
@@ -973,6 +988,12 @@ function openRenameMongoCollectionDialog() {
|
||||
prepareRenameMongoCollectionDialog();
|
||||
}
|
||||
|
||||
function openCreateMongoIndexDialog() {
|
||||
claimTreeItemDialogOwnership();
|
||||
routeTreeItemDialogController();
|
||||
prepareCreateMongoIndexDialog();
|
||||
}
|
||||
|
||||
function openRedisDatabaseAliasDialog() {
|
||||
claimTreeItemDialogOwnership();
|
||||
routeTreeItemDialogController();
|
||||
@@ -1080,6 +1101,13 @@ function openMongoTreeData(node: TreeNode) {
|
||||
if (node.type !== "mongo-collection") return;
|
||||
const tab = queryStore.createTab(node.connectionId, node.database, tabTitle, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
queryStore.setTableMeta(tab, {
|
||||
database: node.database,
|
||||
tableName: node.label,
|
||||
tableType: mongoCollectionTableTypeFromNode(node),
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function openSavedSqlFile() {
|
||||
@@ -2076,27 +2104,61 @@ async function confirmBatchDrop() {
|
||||
if (mongoIndexTargets.length) {
|
||||
const grouped = new Map<string, TreeNode[]>();
|
||||
for (const target of mongoIndexTargets) {
|
||||
const key = `${target.connectionId}:${target.database}:${target.tableName || ""}`;
|
||||
const key = JSON.stringify([target.connectionId, target.database, target.tableName || ""]);
|
||||
const list = grouped.get(key) ?? [];
|
||||
list.push(target);
|
||||
grouped.set(key, list);
|
||||
}
|
||||
const mongoIndexGroups = [...grouped.values()];
|
||||
// Confirm every production target before changing any collection so a
|
||||
// cancellation cannot leave a cross-database batch partially applied.
|
||||
for (const groupTargets of mongoIndexGroups) {
|
||||
const groupFirst = groupTargets[0];
|
||||
if (!groupFirst?.connectionId || !groupFirst.database || !groupFirst.tableName) return;
|
||||
const confirmed = await executeWithProductionContextGuard({
|
||||
connection: connectionStore.getConfig(groupFirst.connectionId),
|
||||
database: groupFirst.database,
|
||||
reviewText: groupTargets.map((target) => mongoIndexDropPreview(target, mongoIndexNameForNode(target))).join("\n"),
|
||||
source: t("production.sourceSidebar"),
|
||||
execute: async () => ({ confirmed: true }),
|
||||
});
|
||||
if (confirmed === undefined) return;
|
||||
}
|
||||
|
||||
let droppedCount = 0;
|
||||
for (const groupTargets of grouped.values()) {
|
||||
const first = groupTargets[0];
|
||||
if (!first?.connectionId || !first.database || !first.tableName) continue;
|
||||
await connectionStore.ensureConnected(first.connectionId);
|
||||
let failedCount = 0;
|
||||
let firstGroupError: unknown;
|
||||
for (const groupTargets of mongoIndexGroups) {
|
||||
const groupFirst = groupTargets[0];
|
||||
if (!groupFirst?.connectionId || !groupFirst.database || !groupFirst.tableName) continue;
|
||||
const names = groupTargets.map((target) => mongoIndexNameForNode(target));
|
||||
const result = await api.mongoDropIndexes(first.connectionId, first.database, first.tableName, JSON.stringify(names.length === 1 ? names[0] : names), false);
|
||||
const dropped = new Set(result.dropped_names);
|
||||
droppedCount += result.dropped_names.length;
|
||||
for (const target of groupTargets) {
|
||||
if (!dropped.has(mongoIndexNameForNode(target))) continue;
|
||||
connectionStore.removeTreeNode(target.id);
|
||||
releaseActiveNodeReference([target.id]);
|
||||
try {
|
||||
await connectionStore.ensureConnected(groupFirst.connectionId);
|
||||
const result = await api.mongoDropIndexes(groupFirst.connectionId, groupFirst.database, groupFirst.tableName, JSON.stringify(names.length === 1 ? names[0] : names), names.length === 1);
|
||||
const dropped = new Set(result.dropped_names);
|
||||
droppedCount += result.dropped_names.length;
|
||||
failedCount += mongoDropIndexFailureCount(result);
|
||||
for (const target of groupTargets) {
|
||||
const indexName = mongoIndexNameForNode(target);
|
||||
if (!dropped.has(indexName)) continue;
|
||||
connectionStore.removeTreeNode(target.id);
|
||||
releaseActiveNodeReference([target.id]);
|
||||
}
|
||||
} catch (error) {
|
||||
// A transport-level failure has no per-index payload; retain prior
|
||||
// successes and continue with independent collection groups.
|
||||
failedCount += groupTargets.length;
|
||||
firstGroupError ??= error;
|
||||
} finally {
|
||||
await refreshMongoIndexTreeAfterMutation(groupFirst);
|
||||
}
|
||||
}
|
||||
toast(t("contextMenu.batchDropSuccess", { count: droppedCount }), 3000);
|
||||
if (droppedCount === 0 && firstGroupError && failedCount === mongoIndexTargets.length) throw firstGroupError;
|
||||
if (failedCount > 0) {
|
||||
toast(t("contextMenu.dropIndexesPartialFailure", { success: droppedCount, failed: failedCount }), 5000);
|
||||
} else {
|
||||
toast(t("contextMenu.batchDropSuccess", { count: droppedCount }), 3000);
|
||||
}
|
||||
showBatchDropConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
@@ -3663,6 +3725,17 @@ function databaseSpecificDialogCapabilities() {
|
||||
renameMongoCollectionPreview,
|
||||
renameMongoCollectionLoading,
|
||||
confirmRenameMongoCollection,
|
||||
showCreateMongoIndexDialog,
|
||||
mongoCreateIndexForm,
|
||||
mongoCreateIndexFieldOptions,
|
||||
mongoCreateIndexError,
|
||||
mongoCreateIndexLoading,
|
||||
mongoIndexKeyTypes,
|
||||
mongoCreateIndexCanSubmit,
|
||||
mongoCreateIndexCanAddField,
|
||||
addMongoCreateIndexField,
|
||||
removeMongoCreateIndexField,
|
||||
confirmCreateMongoIndex,
|
||||
showRedisDatabaseAliasDialog,
|
||||
redisDatabaseAliasInput,
|
||||
redisDatabaseAliasSaving,
|
||||
@@ -4213,8 +4286,11 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
shortcut: shortcutRename,
|
||||
});
|
||||
}
|
||||
if (canDropAllMongoIndexes.value || canDropMongoCollection.value) {
|
||||
if (canCreateMongoIndex.value || canDropAllMongoIndexes.value || canDropMongoCollection.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
if (canCreateMongoIndex.value) {
|
||||
items.push({ label: t("contextMenu.createMongoIndex"), action: openCreateMongoIndexDialog, icon: Plus });
|
||||
}
|
||||
if (canDropAllMongoIndexes.value) {
|
||||
items.push({ label: t("contextMenu.dropAllIndexes"), action: dropAllMongoIndexes, icon: Trash2, variant: "destructive" as const });
|
||||
}
|
||||
@@ -4486,7 +4562,8 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
|
||||
// 9. Group Labels (group-columns, group-tables, etc.)
|
||||
if (isGroupLabel(node)) {
|
||||
const mysqlObjectTemplate = node.connectionId ? mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null;
|
||||
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database) || !!mysqlObjectTemplate;
|
||||
const hasMongoCreateIndexAction = node.type === "group-indexes" && canCreateMongoIndex.value;
|
||||
const hasGroupCreateAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database) || !!mysqlObjectTemplate || hasMongoCreateIndexAction;
|
||||
const canLoadAllObjectGroup = node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views";
|
||||
if (node.type === "group-tables" && canCreateTable.value) {
|
||||
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
|
||||
@@ -4503,6 +4580,9 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea
|
||||
if (mysqlObjectTemplate) {
|
||||
items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus });
|
||||
}
|
||||
if (hasMongoCreateIndexAction) {
|
||||
items.push({ label: t("contextMenu.createMongoIndex"), action: openCreateMongoIndexDialog, icon: Plus });
|
||||
}
|
||||
if (hasGroupCreateAction) {
|
||||
items.push({ label: "", separator: true });
|
||||
}
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import SidebarTreeItemDialogs from "../SidebarTreeItemDialogs.vue";
|
||||
|
||||
const mountedApps: ReturnType<typeof createApp>[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("SidebarTreeItemDialogs MongoDB index form", () => {
|
||||
it("mounts the form and overlay from the same controlled dialog state", async () => {
|
||||
const confirmCreateMongoIndex = vi.fn();
|
||||
const closed = vi.fn();
|
||||
const controller = reactive({
|
||||
node: { id: "mongo:app:users:__indexes", label: "索引", type: "group-indexes", database: "app", tableName: "users" },
|
||||
t: (key: string) => key,
|
||||
highlight: (value: string) => value,
|
||||
showDeleteConfirm: false,
|
||||
showMoveToNewGroupDialog: false,
|
||||
showDeleteGroupConfirm: false,
|
||||
showRenameObjectDialog: false,
|
||||
showStructurePreviewDialog: false,
|
||||
showStructureDocCopyDialog: false,
|
||||
showDuplicateDialog: false,
|
||||
showPasteDialog: false,
|
||||
showCreateDatabaseDialog: false,
|
||||
showCreateDatabasePreviewDialog: false,
|
||||
showEditDatabasePropertiesDialog: false,
|
||||
showCreateNacosNamespaceDialog: false,
|
||||
showEditNacosNamespaceDialog: false,
|
||||
showRenameMongoCollectionDialog: false,
|
||||
showCreateMongoIndexDialog: false,
|
||||
mongoCreateIndexForm: { name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false },
|
||||
mongoCreateIndexFieldOptions: ["email", "createdAt"],
|
||||
mongoCreateIndexError: `Kind: Command failed: Error code 11000 (DuplicateKey): ${"a".repeat(400)}`,
|
||||
mongoCreateIndexLoading: false,
|
||||
mongoIndexKeyTypes: ["1", "-1", "text", "hashed", "2dsphere", "2d"],
|
||||
mongoCreateIndexCanSubmit: false,
|
||||
mongoCreateIndexCanAddField: false,
|
||||
addMongoCreateIndexField: vi.fn(),
|
||||
removeMongoCreateIndexField: vi.fn(),
|
||||
confirmCreateMongoIndex,
|
||||
showRedisDatabaseAliasDialog: false,
|
||||
showCreateSchemaDialog: false,
|
||||
showEditSchemaCommentDialog: false,
|
||||
});
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(SidebarTreeItemDialogs, { controller, onClosed: closed }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.mount(document.createElement("div"));
|
||||
await nextTick();
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
|
||||
|
||||
controller.showCreateMongoIndexDialog = true;
|
||||
await nextTick();
|
||||
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
const name = document.querySelector<HTMLInputElement>('[aria-label="structureEditor.indexName"]');
|
||||
const type = document.querySelector<HTMLElement>('[aria-label="structureEditor.indexType"]');
|
||||
const unique = document.querySelector<HTMLElement>('[aria-label="structureEditor.unique"]');
|
||||
expect(dialog?.getAttribute("data-state")).toBe("open");
|
||||
expect(document.querySelectorAll('[data-slot="dialog-overlay"]')).toHaveLength(1);
|
||||
expect(dialog?.contains(name!)).toBe(true);
|
||||
expect(dialog?.contains(type!)).toBe(true);
|
||||
expect(dialog?.contains(unique!)).toBe(true);
|
||||
expect(dialog?.textContent).not.toContain("JSON");
|
||||
const error = dialog?.querySelector<HTMLElement>(".text-destructive");
|
||||
expect(error?.classList.contains("min-w-0")).toBe(true);
|
||||
expect(error?.classList.contains("max-w-full")).toBe(true);
|
||||
expect(error?.classList.contains("whitespace-pre-wrap")).toBe(true);
|
||||
expect(error?.classList.contains("break-all")).toBe(true);
|
||||
|
||||
unique?.click();
|
||||
await nextTick();
|
||||
expect(controller.mongoCreateIndexForm.unique).toBe(true);
|
||||
|
||||
controller.mongoCreateIndexForm.fields[0].path = "email";
|
||||
controller.mongoCreateIndexForm.name = "codex_e2e_email_1";
|
||||
controller.mongoCreateIndexCanSubmit = true;
|
||||
await nextTick();
|
||||
const createButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "contextMenu.createMongoIndex");
|
||||
expect(createButton?.disabled).toBe(false);
|
||||
createButton?.click();
|
||||
expect(confirmCreateMongoIndex).toHaveBeenCalledOnce();
|
||||
|
||||
controller.showCreateMongoIndexDialog = false;
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
|
||||
});
|
||||
expect(closed).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { PasteTableMode } from "@/lib/table/tableClipboard";
|
||||
import { fallbackCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
|
||||
import type { DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
|
||||
import type { AuthorizationPlan, AuthorizationStepResult } from "@/lib/database/databaseAuthorizationPlan";
|
||||
import type { MongoCreateIndexForm } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
|
||||
export type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
|
||||
type ConnectionDeleteTarget = TreeNode & { connectionId: string };
|
||||
@@ -96,6 +97,18 @@ export const showDropMongoIndexConfirm = ref(false);
|
||||
export const dropMongoIndexLoading = ref(false);
|
||||
export const showDropAllMongoIndexesConfirm = ref(false);
|
||||
export const dropAllMongoIndexesLoading = ref(false);
|
||||
export const showCreateMongoIndexDialog = ref(false);
|
||||
export const mongoCreateIndexForm = ref<MongoCreateIndexForm>({ name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false });
|
||||
export const mongoCreateIndexFieldOptions = ref<string[]>([]);
|
||||
export const mongoCreateIndexError = ref("");
|
||||
export const mongoCreateIndexLoading = ref(false);
|
||||
|
||||
export function resetMongoCreateIndexForm() {
|
||||
mongoCreateIndexForm.value = { name: "", fields: [{ id: 1, path: "", type: "1" }], unique: false, sparse: false };
|
||||
mongoCreateIndexFieldOptions.value = [];
|
||||
mongoCreateIndexError.value = "";
|
||||
mongoCreateIndexLoading.value = false;
|
||||
}
|
||||
export const showFlushRedisDbConfirm = ref(false);
|
||||
export const showRedisDatabaseAliasDialog = ref(false);
|
||||
export const redisDatabaseAliasInput = ref("");
|
||||
@@ -141,6 +154,7 @@ const openFlags = [
|
||||
showRenameMongoCollectionDialog,
|
||||
showDropMongoIndexConfirm,
|
||||
showDropAllMongoIndexesConfirm,
|
||||
showCreateMongoIndexDialog,
|
||||
showFlushRedisDbConfirm,
|
||||
showRedisDatabaseAliasDialog,
|
||||
showCreateSchemaDialog,
|
||||
@@ -162,6 +176,7 @@ export function resetSidebarTreeDialogState() {
|
||||
createDatabaseAuthorizationApplying.value = false;
|
||||
redisDatabaseAliasInput.value = "";
|
||||
redisDatabaseAliasSaving.value = false;
|
||||
resetMongoCreateIndexForm();
|
||||
sidebarTreeDialogOwner.value = null;
|
||||
sidebarDangerTarget.value = null;
|
||||
sidebarFormTarget.value = null;
|
||||
|
||||
Reference in new issue
Block a user