From a2e112867057591256ff325764ea48b367a0deac Mon Sep 17 00:00:00 2001 From: onenewcode Date: Wed, 5 Aug 2026 01:02:21 +0800 Subject: [PATCH] fix(mongodb): move sidebar index actions to indexes group --- .../sidebar/SidebarTreeRuntimeHost.vue | 41 ++++++------- ...abaseSpecificMutationRuntime.mongo.spec.ts | 58 ++++++++++++++++--- ...eSidebarDatabaseSpecificMutationRuntime.ts | 26 +++++---- .../productionGuardEntrypoints.test.ts | 10 +++- packages/app-tests/treeNodeClick.test.ts | 5 +- 5 files changed, 93 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 04303248f..84ebb5831 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -3577,7 +3577,7 @@ routeDangerDialog(showDropMongoIndexConfirm, () => routeDangerDialog(showDropAllMongoIndexesConfirm, () => dangerRequest({ title: t("contextMenu.dropAllIndexes"), - message: t("contextMenu.confirmDropMongoAllIndexesMessage", { name: activeNode.value.label }), + message: t("contextMenu.confirmDropMongoAllIndexesMessage", { name: activeNode.value.tableName || activeNode.value.label }), detailsText: t("contextMenu.confirmDropMongoAllIndexesDetails"), sql: mongoDropAllIndexesPreview(activeNode.value), confirmLabel: t("contextMenu.dropAllIndexes"), @@ -4248,17 +4248,13 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { } if (canDropMongoDatabase.value) { items.push({ label: "", separator: true }); - items.push( - moreActionsSubmenu([ - { - label: t("contextMenu.dropDatabase"), - action: dropDatabase, - icon: Trash2, - shortcut: shortcutDelete, - variant: "destructive" as const, - }, - ]), - ); + items.push({ + label: t("contextMenu.dropDatabase"), + action: dropDatabase, + icon: Trash2, + shortcut: shortcutDelete, + variant: "destructive" as const, + }); } return true; } @@ -4292,17 +4288,9 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { shortcut: shortcutRename, }); } - if (canCreateMongoIndex.value || canDropAllMongoIndexes.value || canDropMongoCollection.value) { + if (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 }); - } - if (canDropMongoCollection.value) { - items.push({ label: t("contextMenu.dropCollection"), action: dropMongoCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const }); - } + items.push({ label: t("contextMenu.dropCollection"), action: dropMongoCollection, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const }); } return true; } @@ -4569,7 +4557,8 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea if (isGroupLabel(node)) { const mysqlObjectTemplate = node.connectionId ? mysqlObjectTemplateForGroup(connectionStore.getConfig(node.connectionId), node) : null; 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 hasMongoDropAllIndexesAction = node.type === "group-indexes" && canDropAllMongoIndexes.value; + const hasGroupAction = (node.type === "group-tables" && canCreateTable.value) || (node.type === "group-views" && !!node.connectionId && !!node.database) || !!mysqlObjectTemplate || hasMongoCreateIndexAction || hasMongoDropAllIndexesAction; 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 }); @@ -4589,7 +4578,11 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea if (hasMongoCreateIndexAction) { items.push({ label: t("contextMenu.createMongoIndex"), action: openCreateMongoIndexDialog, icon: Plus }); } - if (hasGroupCreateAction) { + if (hasMongoDropAllIndexesAction) { + if (hasMongoCreateIndexAction) items.push({ label: "", separator: true }); + items.push({ label: t("contextMenu.dropAllIndexes"), action: dropAllMongoIndexes, icon: Trash2, variant: "destructive" as const }); + } + if (hasGroupAction) { items.push({ label: "", separator: true }); } if (node.type === "group-extensions") { diff --git a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts index c1d1956a7..6f1447656 100644 --- a/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSidebarDatabaseSpecificMutationRuntime.mongo.spec.ts @@ -153,7 +153,7 @@ describe("MongoDB sidebar mutation runtime", () => { resetMongoCreateIndexForm(); }); - it("allows Legacy connections to create and delete MongoDB tree objects while keeping rename native-only", () => { + it("keeps Legacy MongoDB mutations available while limiting index actions to the Indexes group", () => { mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy")); const activeNode = shallowRef(mongoDatabaseNode()); const feature = useSidebarDatabaseSpecificMutationRuntime({ @@ -172,10 +172,14 @@ describe("MongoDB sidebar mutation runtime", () => { expect(feature.canDropMongoDatabase.value).toBe(true); activeNode.value = mongoCollectionNode(); expect(feature.canDropMongoCollection.value).toBe(true); - expect(feature.canDropAllMongoIndexes.value).toBe(true); + expect(feature.canDropAllMongoIndexes.value).toBe(false); expect(feature.canRenameMongoCollection.value).toBe(false); - expect(feature.canCreateMongoIndex.value).toBe(true); + expect(feature.canCreateMongoIndex.value).toBe(false); activeNode.value = mongoIndexesGroupNode(); + expect(feature.canDropAllMongoIndexes.value).toBe(true); + expect(feature.canCreateMongoIndex.value).toBe(true); + activeNode.value = mongoIndexesGroupNode("timeseries"); + expect(feature.canDropAllMongoIndexes.value).toBe(true); expect(feature.canCreateMongoIndex.value).toBe(true); activeNode.value = mongoIndexNode("email_1"); expect(feature.canDropMongoIndex.value).toBe(true); @@ -201,11 +205,20 @@ describe("MongoDB sidebar mutation runtime", () => { expect(feature.canDropMongoCollection.value).toBe(true); expect(feature.canDropAllMongoIndexes.value).toBe(false); activeNode.value = mongoIndexesGroupNode("view"); + expect(feature.canDropAllMongoIndexes.value).toBe(false); expect(feature.canCreateMongoIndex.value).toBe(false); activeNode.value = mongoIndexNode("email_1", "view"); expect(feature.canDropMongoIndex.value).toBe(false); }); + it("does not expose Indexes group mutations for read-only MongoDB connections", () => { + mocks.getConfig.mockReturnValue({ ...mongoConfig(), read_only: true }); + const feature = runtime(mongoIndexesGroupNode()); + + expect(feature.canCreateMongoIndex.value).toBe(false); + expect(feature.canDropAllMongoIndexes.value).toBe(false); + }); + it("creates an index from the shared sidebar dialog state", async () => { mocks.getConfig.mockReturnValue(mongoConfig("mongodb-legacy")); const node = mongoIndexesGroupNode(); @@ -253,16 +266,26 @@ describe("MongoDB sidebar mutation runtime", () => { expect(showCreateMongoIndexDialog.value).toBe(true); }); - it("allows a collection menu to create an index before its Indexes group is expanded", async () => { + it("does not expose index creation through a collection node", async () => { const node = mongoCollectionNode(); const feature = runtime(node); sidebarFormTarget.value = node; feature.prepareCreateMongoIndexDialog(); - mongoCreateIndexForm.value.fields[0]!.path = "email"; + expect(showCreateMongoIndexDialog.value).toBe(false); await feature.confirmCreateMongoIndex(); - expect(mocks.mongoCreateIndex).toHaveBeenCalledWith("conn-1", "app", "users", '{"email":1}', undefined); + expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); + }); + + it("does not clear indexes through a collection node", async () => { + const node = mongoCollectionNode(); + const feature = runtime(node); + sidebarDangerTarget.value = node; + + await feature.confirmDropAllMongoIndexes(); + + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); }); it("keeps the sidebar form target when the active node changes", async () => { @@ -296,12 +319,13 @@ describe("MongoDB sidebar mutation runtime", () => { }); it("drops every removable index through the shared mutation and refreshes metadata", async () => { - const node = mongoCollectionNode(); + const node = mongoIndexesGroupNode(); const feature = runtime(node); sidebarDangerTarget.value = node; showDropAllMongoIndexesConfirm.value = true; mocks.mongoDropIndexes.mockResolvedValueOnce({ dropped_names: ["email_1", "created_at_-1"], affected_rows: 2 }); + expect(feature.mongoDropAllIndexesPreview(node)).toBe('db.getSiblingDB("app").getCollection("users").dropIndexes()'); await feature.confirmDropAllMongoIndexes(); expect(mocks.mongoDropIndexes).toHaveBeenCalledWith("conn-1", "app", "users", undefined, false); @@ -323,7 +347,7 @@ describe("MongoDB sidebar mutation runtime", () => { }); it("reports partial index deletion after forcing a metadata refresh", async () => { - const node = mongoCollectionNode(); + const node = mongoIndexesGroupNode(); const feature = runtime(node); sidebarDangerTarget.value = node; mocks.mongoDropIndexes.mockResolvedValueOnce({ @@ -432,4 +456,22 @@ describe("MongoDB sidebar mutation runtime", () => { expect(mocks.mongoCreateIndex).not.toHaveBeenCalled(); expect(showCreateMongoIndexDialog.value).toBe(true); }); + + it("does not clear indexes when production confirmation is cancelled", async () => { + mocks.getConfig.mockReturnValue(mongoConfig(undefined, true)); + const node = mongoIndexesGroupNode(); + const feature = runtime(node); + sidebarDangerTarget.value = node; + showDropAllMongoIndexesConfirm.value = true; + const pending = feature.confirmDropAllMongoIndexes(); + await Promise.resolve(); + + const { useProductionSafetyStore } = await import("@/stores/productionSafetyStore"); + useProductionSafetyStore().cancel(); + await pending; + + expect(mocks.ensureConnected).not.toHaveBeenCalled(); + expect(mocks.mongoDropIndexes).not.toHaveBeenCalled(); + expect(showDropAllMongoIndexesConfirm.value).toBe(true); + }); }); diff --git a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts index 2555345be..8f1171067 100644 --- a/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts @@ -175,19 +175,26 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba const canDropMongoIndex = computed(() => canDropMongoIndexNode(activeNode.value)); - const canDropAllMongoIndexes = computed(() => activeNode.value.type === "mongo-collection" && canMutateMongoIndexes(activeNode.value)); + function mongoIndexCollectionName(node: TreeNode): string { + return node.type === "group-indexes" ? node.tableName || "" : ""; + } + + function canManageMongoIndexesNode(node: TreeNode): boolean { + return !!mongoIndexCollectionName(node) && !!node.database && canMutateMongoIndexes(node); + } + + const canDropAllMongoIndexes = computed(() => canManageMongoIndexesNode(activeNode.value)); function mongoIndexDropPreview(node: Pick, indexName: string): string { return mongoDropIndexPreview(node.database || "", node.tableName || "", indexName); } - function mongoDropAllIndexesPreviewForNode(node: Pick): string { - return mongoDropAllIndexesPreview(node.database || "", node.label); + function mongoDropAllIndexesPreviewForNode(node: TreeNode): string { + return mongoDropAllIndexesPreview(node.database || "", mongoIndexCollectionName(node)); } function canCreateMongoIndexNode(node: TreeNode): boolean { - const collectionName = mongoIndexCollectionName(node); - return !!collectionName && !!node.database && canMutateMongoIndexes(node); + return canManageMongoIndexesNode(node); } const canCreateMongoIndex = computed(() => canCreateMongoIndexNode(activeNode.value)); @@ -202,11 +209,6 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba { deep: true }, ); - function mongoIndexCollectionName(node: TreeNode): string { - if (node.type === "mongo-collection") return node.label; - return node.type === "group-indexes" ? node.tableName || "" : ""; - } - function prepareCreateMongoIndexDialog() { const node = activeNode.value; if (!canCreateMongoIndexNode(node) || !node.connectionId || !node.database) return; @@ -505,8 +507,8 @@ export function useSidebarDatabaseSpecificMutationRuntime(options: SidebarDataba const node = sidebarDangerTarget.value ?? activeNode.value; const connectionId = node.connectionId; const database = node.database; - if (node.type !== "mongo-collection" || !canMutateMongoIndexes(node) || !connectionId || !database) return; - const collectionName = node.label; + if (!canManageMongoIndexesNode(node) || !connectionId || !database) return; + const collectionName = mongoIndexCollectionName(node); await runMongoSidebarMutation({ connection: connectionStore.getConfig(connectionId), database, diff --git a/packages/app-tests/productionGuardEntrypoints.test.ts b/packages/app-tests/productionGuardEntrypoints.test.ts index e12db6fb8..646867076 100644 --- a/packages/app-tests/productionGuardEntrypoints.test.ts +++ b/packages/app-tests/productionGuardEntrypoints.test.ts @@ -127,8 +127,14 @@ test("mongo sidebar mutations share the production-gated runMongoSidebarMutation const dropDatabaseBody = functionBody(hostSource, "confirmDropDatabase"); assert.match(dropDatabaseBody, /confirmDropMongoDatabase/, "host drop-database confirm should delegate mongo to the mutation runtime"); assert.ok(!dropDatabaseBody.includes("api.mongoDropDatabase"), "host drop-database confirm should not call mongo APIs directly"); - const mongoCollectionMenuBody = functionBody(hostSource, "buildSpecialSidebarMenu"); - assert.match(mongoCollectionMenuBody, /action:\s*dropAllMongoIndexes/, "collection context menu must keep the drop-all-indexes entrypoint"); + const mongoSpecialMenuBody = functionBody(hostSource, "buildSpecialSidebarMenu"); + const mongoIndexGroupMenuBody = functionBody(hostSource, "buildObjectGroupSidebarMenu"); + assert.match(mongoSpecialMenuBody, /items\.push\(\{\s*label: t\("contextMenu\.dropDatabase"\),\s*action: dropDatabase/, "MongoDB database deletion must be a top-level menu action"); + assert.doesNotMatch(mongoSpecialMenuBody, /moreActionsSubmenu\(\[\s*\{\s*label: t\("contextMenu\.dropDatabase"\)/, "MongoDB database deletion must not be nested under More"); + assert.doesNotMatch(mongoSpecialMenuBody, /action:\s*openCreateMongoIndexDialog/, "collection context menu must not expose index creation"); + assert.doesNotMatch(mongoSpecialMenuBody, /action:\s*dropAllMongoIndexes/, "collection context menu must not expose the drop-all-indexes entrypoint"); + assert.match(mongoIndexGroupMenuBody, /action:\s*openCreateMongoIndexDialog/, "Indexes group context menu must expose index creation"); + assert.match(mongoIndexGroupMenuBody, /action:\s*dropAllMongoIndexes/, "Indexes group context menu must expose the drop-all-indexes entrypoint"); const batchDropBody = functionBody(hostSource, "confirmBatchDrop"); assert.match(batchDropBody, /catch\s*\([^)]*\)\s*\{[\s\S]*?failedCount \+= groupTargets\.length/, "cross-collection index deletion must retain earlier successes after a group failure"); assert.match(batchDropBody, /droppedCount === 0[\s\S]*?throw firstGroupError/, "an entirely failed cross-collection request must preserve its original error"); diff --git a/packages/app-tests/treeNodeClick.test.ts b/packages/app-tests/treeNodeClick.test.ts index 28564be6b..7ab016947 100644 --- a/packages/app-tests/treeNodeClick.test.ts +++ b/packages/app-tests/treeNodeClick.test.ts @@ -1,7 +1,10 @@ -import { test } from "vitest"; +import { afterAll, beforeAll, test, vi } from "vitest"; import assert from "node:assert/strict"; import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "../../apps/desktop/src/lib/sidebar/treeNodeClick.ts"; +beforeAll(() => vi.stubGlobal("navigator", { platform: "Linux x86_64" })); +afterAll(() => vi.unstubAllGlobals()); + test("table and view rows open data without toggling structure groups", () => { assert.equal(treeNodeRowAction("table", true), "open-data"); assert.equal(treeNodeRowAction("view", true), "open-data");