fix(mongodb): move sidebar index actions to indexes group

This commit is contained in:
onenewcode 2026-08-05 01:02:21 +08:00 committed by GitHub
parent e82c1aedf0
commit a2e1128670
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 93 additions and 47 deletions

View File

@ -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") {

View File

@ -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);
});
});

View File

@ -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<TreeNode, "database" | "tableName">, indexName: string): string {
return mongoDropIndexPreview(node.database || "", node.tableName || "", indexName);
}
function mongoDropAllIndexesPreviewForNode(node: Pick<TreeNode, "database" | "label">): 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,

View File

@ -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");

View File

@ -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");