1 parent
3f1544d45e
commit
de99931c04
7 files changed
+376
-4
No files matched your search
@@ -1295,6 +1295,14 @@ async function startRenamingSavedSqlNode(nodeId: string) {
|
||||
store.selectedTreeNodeId = nodeId;
|
||||
}
|
||||
|
||||
async function startRenamingConnectionNode(connectionId: string) {
|
||||
pendingRenameNodeId.value = connectionId;
|
||||
store.selectedTreeNodeId = connectionId;
|
||||
store.selectedTreeNodeIds = [connectionId];
|
||||
await scrollToSidebarNode(connectionId);
|
||||
store.selectedTreeNodeId = connectionId;
|
||||
}
|
||||
|
||||
async function locateActiveTabInSidebar() {
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return;
|
||||
@@ -2176,6 +2184,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
|
||||
@open-visible-nacos-namespaces="openSidebarVisibleNacosNamespaces"
|
||||
@open-table-name-filters="openSidebarTableNameFilters"
|
||||
@add-to-ai="(node) => emit('add-to-ai', node)"
|
||||
@request-connection-rename="startRenamingConnectionNode"
|
||||
@request-group-rename="startRenamingCreatedGroup"
|
||||
@request-saved-sql-rename="startRenamingSavedSqlNode"
|
||||
@open-danger-dialog="openSidebarDangerDialog"
|
||||
|
||||
@@ -369,6 +369,7 @@ const { openAllDatabasesExport, openDataCompare, openDatabaseExport, openDatabas
|
||||
|
||||
const emit = defineEmits<{
|
||||
"rename-started": [];
|
||||
"request-connection-rename": [connectionId: string];
|
||||
"request-group-rename": [groupId: string];
|
||||
"request-saved-sql-rename": [nodeId: string];
|
||||
"node-toggled": [node: TreeNode, expanded: boolean];
|
||||
@@ -1209,7 +1210,7 @@ function requestRenameSelectedNode(): boolean {
|
||||
if (selected.length > 1 && selected.some((node) => node.id === activeNode.value.id)) return false;
|
||||
const editTarget = selectedConnectionEditTarget(activeNode.value, selected);
|
||||
if (editTarget) {
|
||||
connectionStore.startEditing(editTarget.connectionId);
|
||||
emit("request-connection-rename", editTarget.connectionId);
|
||||
return true;
|
||||
}
|
||||
if (canRenameMongoCollection.value) {
|
||||
|
||||
@@ -935,6 +935,8 @@ const isRenamingGroup = ref(false);
|
||||
|
||||
const isRenamingSavedSql = ref(false);
|
||||
|
||||
const isRenamingConnection = ref(false);
|
||||
|
||||
const renameInput = ref("");
|
||||
|
||||
const renameInputRef = ref<HTMLInputElement>();
|
||||
@@ -954,12 +956,21 @@ function startRenameSavedSql() {
|
||||
focusSidebarRenameInput(() => (isRenamingSavedSql.value ? renameInputRef.value : undefined));
|
||||
}
|
||||
|
||||
function startRenameConnection() {
|
||||
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return;
|
||||
renameInput.value = activeNode.value.label;
|
||||
isRenamingConnection.value = true;
|
||||
emit("rename-started");
|
||||
focusSidebarRenameInput(() => (isRenamingConnection.value ? renameInputRef.value : undefined));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.pendingRename,
|
||||
(pending) => {
|
||||
if (!pending) return;
|
||||
if (activeNode.value.type === "connection-group") startRenameGroup();
|
||||
else if (activeNode.value.type === "saved-sql-file") startRenameSavedSql();
|
||||
else if (activeNode.value.type === "connection") startRenameConnection();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -967,7 +978,7 @@ watch(
|
||||
function shouldMeasureLabelOverflow(): boolean {
|
||||
return shouldMeasureSidebarLabelOverflow({
|
||||
hasDetailTooltip: !!detailTooltip.value?.rows.length,
|
||||
isRenaming: isRenamingGroup.value || isRenamingSavedSql.value,
|
||||
isRenaming: isRenamingGroup.value || isRenamingSavedSql.value || isRenamingConnection.value,
|
||||
usesFullWidthLabel: usesFullWidthLabel.value,
|
||||
});
|
||||
}
|
||||
@@ -1001,14 +1012,29 @@ async function finishRenameSavedSql() {
|
||||
}
|
||||
}
|
||||
|
||||
async function finishRenameConnection() {
|
||||
if (!isRenamingConnection.value) return;
|
||||
isRenamingConnection.value = false;
|
||||
const connectionId = activeNode.value.connectionId;
|
||||
const trimmed = renameInput.value.trim();
|
||||
if (!connectionId || !trimmed || trimmed === activeNode.value.label) return;
|
||||
try {
|
||||
await connectionStore.renameConnection(connectionId, trimmed);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function finishRename() {
|
||||
if (isRenamingSavedSql.value) void finishRenameSavedSql();
|
||||
if (isRenamingConnection.value) void finishRenameConnection();
|
||||
else if (isRenamingSavedSql.value) void finishRenameSavedSql();
|
||||
else finishRenameGroup();
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
isRenamingGroup.value = false;
|
||||
isRenamingSavedSql.value = false;
|
||||
isRenamingConnection.value = false;
|
||||
}
|
||||
|
||||
const PINNED_TREE_NODE_DRAG_TYPE = "__pinned-tree-node__";
|
||||
@@ -1232,6 +1258,7 @@ watch(
|
||||
// from the previously rendered node into the new row.
|
||||
isRenamingGroup.value = false;
|
||||
isRenamingSavedSql.value = false;
|
||||
isRenamingConnection.value = false;
|
||||
renameInput.value = "";
|
||||
labelOverflowing.value = false;
|
||||
suppressNextTableReferenceClick = false;
|
||||
@@ -1399,7 +1426,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
<div ref="trailingCommentLayoutRef" :class="hasTrailingMetadata() ? 'flex flex-1 min-w-0 items-center' : 'contents'">
|
||||
<div ref="trailingCommentLeadingRef" :class="trailingComment ? 'flex max-w-full min-w-0 shrink-0 items-center gap-2' : formattedObjectStorage() ? 'flex min-w-0 flex-1 items-center gap-2' : 'contents'" :style="alignedCommentLeadingStyle()">
|
||||
<input
|
||||
v-if="isRenamingGroup || isRenamingSavedSql"
|
||||
v-if="isRenamingGroup || isRenamingSavedSql || isRenamingConnection"
|
||||
ref="renameInputRef"
|
||||
v-model="renameInput"
|
||||
class="min-w-0 flex-1 truncate bg-transparent border border-primary/50 rounded px-1 outline-none"
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import TreeItem from "@/components/sidebar/TreeItem.vue";
|
||||
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime";
|
||||
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
|
||||
|
||||
const renameConnection = vi.fn();
|
||||
const toast = vi.fn();
|
||||
const connection: ConnectionConfig = {
|
||||
id: "connection-1",
|
||||
name: "Reporting",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "",
|
||||
};
|
||||
const connectionStore = {
|
||||
activeConnectionId: connection.id,
|
||||
connectedIds: new Set([connection.id]),
|
||||
connectingIds: new Set<string>(),
|
||||
connectionErrors: {},
|
||||
connectionMultiSelectActive: false,
|
||||
connections: [connection],
|
||||
getConfig: () => connection,
|
||||
getSidebarVisibleFilterSummary: () => null,
|
||||
isDefaultDatabase: () => false,
|
||||
isDefaultSchema: () => false,
|
||||
isPinnedTreeNodeReorderTarget: () => false,
|
||||
isTreeNodeChildrenLoaded: () => false,
|
||||
isTreeNodePinned: () => false,
|
||||
renameConnection,
|
||||
selectedTreeNodeId: connection.id as string | null,
|
||||
selectedTreeNodeIds: [connection.id],
|
||||
selectedTreeNodeIdsSet: new Set([connection.id]),
|
||||
sidebarLayout: { groups: [], order: [] } as SidebarLayout,
|
||||
sidebarTableSearchQueries: {},
|
||||
tableNameFilterForScope: () => undefined,
|
||||
treeNodes: [] as TreeNode[],
|
||||
treeSelectionAnchorId: connection.id as string | null,
|
||||
};
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => connectionStore,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/queryStore", () => ({
|
||||
useQueryStore: () => ({ openDatabaseKeys: new Set<string>() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
shortcuts: { openDataInNewTab: "" },
|
||||
sidebarActivation: "double",
|
||||
sidebarAllowHorizontalScroll: false,
|
||||
sidebarHiddenTablePrefixes: [],
|
||||
sidebarObjectInfoMode: "none",
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({ default: { template: "<span />" } }));
|
||||
vi.mock("@/components/connection/ConnectionErrorIndicator.vue", () => ({ default: { template: "<span />" } }));
|
||||
vi.mock("@/components/common/ProductionContextBadge.vue", () => ({ default: { template: "<span />" } }));
|
||||
vi.mock("@/components/ui/badge", () => ({ Badge: { template: "<span><slot /></span>" } }));
|
||||
vi.mock("@/components/ui/input", () => ({ Input: { template: "<input />" } }));
|
||||
vi.mock("@/components/ui/switch", () => ({ Switch: { template: "<input />" } }));
|
||||
vi.mock("@/components/ui/LightTooltip.vue", () => ({ default: { template: "<span><slot /></span>" } }));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: {
|
||||
en: {
|
||||
connection: {
|
||||
saveFailed: "Failed to save connection: {message}",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function runtimeHost(): SidebarTreeRuntimeHost {
|
||||
return {
|
||||
buildContextMenu: vi.fn(() => []),
|
||||
handleRowClick: vi.fn(),
|
||||
handleRowDoubleClick: vi.fn(),
|
||||
handleRowKeydown: vi.fn(),
|
||||
openPrimaryVisibleFilter: vi.fn(),
|
||||
openDataInNewTab: vi.fn(),
|
||||
requestPaste: vi.fn(() => false),
|
||||
toggleNode: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
async function mountConnectionRename() {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const node: TreeNode = {
|
||||
id: connection.id,
|
||||
label: connection.name,
|
||||
type: "connection",
|
||||
connectionId: connection.id,
|
||||
children: [],
|
||||
};
|
||||
const runtime = createSidebarTreeRuntime();
|
||||
runtime.bindHost(runtimeHost());
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(TreeItem, { node, depth: 0, pendingRename: true }),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.provide(sidebarTreeRuntimeKey, runtime);
|
||||
app.mount(container);
|
||||
await nextTick();
|
||||
const input = container.querySelector<HTMLInputElement>("input");
|
||||
if (!input) throw new Error(`Connection rename input was not rendered: ${container.innerHTML}`);
|
||||
return { container, input };
|
||||
}
|
||||
|
||||
function typeName(input: HTMLInputElement, value: string) {
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
function press(input: HTMLInputElement, key: string) {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
renameConnection.mockReset().mockResolvedValue(true);
|
||||
toast.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("TreeItem connection quick rename", () => {
|
||||
it("trims and persists the edited name on Enter", async () => {
|
||||
const { input } = await mountConnectionRename();
|
||||
typeName(input, " Reporting EU ");
|
||||
|
||||
press(input, "Enter");
|
||||
|
||||
await vi.waitFor(() => expect(renameConnection).toHaveBeenCalledWith(connection.id, "Reporting EU"));
|
||||
expect(renameConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["", " ", connection.name])("cancels blank or unchanged input %j", async (name) => {
|
||||
const { input } = await mountConnectionRename();
|
||||
typeName(input, name);
|
||||
|
||||
press(input, "Enter");
|
||||
await nextTick();
|
||||
|
||||
expect(renameConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels on Escape without persisting", async () => {
|
||||
const { container, input } = await mountConnectionRename();
|
||||
typeName(input, "Reporting EU");
|
||||
|
||||
press(input, "Escape");
|
||||
await nextTick();
|
||||
|
||||
expect(renameConnection).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("input")).toBeNull();
|
||||
expect(container.textContent).toContain(connection.name);
|
||||
});
|
||||
|
||||
it("surfaces persistence failures", async () => {
|
||||
renameConnection.mockRejectedValueOnce(new Error("disk full"));
|
||||
const { input } = await mountConnectionRename();
|
||||
typeName(input, "Reporting EU");
|
||||
|
||||
press(input, "Enter");
|
||||
|
||||
await vi.waitFor(() => expect(toast).toHaveBeenCalledWith(expect.stringContaining("disk full"), 5000));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
|
||||
|
||||
function postgresConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "pg-1",
|
||||
name: "Reporting",
|
||||
db_type: "postgres",
|
||||
host: "127.0.0.1",
|
||||
port: 5432,
|
||||
username: "postgres",
|
||||
password: "secret",
|
||||
database: "analytics",
|
||||
read_only: false,
|
||||
note: "Production reporting",
|
||||
...overrides,
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
async function createStore(saveConnections: ReturnType<typeof vi.fn>) {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
saveConnections,
|
||||
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
return useConnectionStore();
|
||||
}
|
||||
|
||||
describe("connectionStore quick rename", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("persists only the trimmed name while preserving runtime and tree identity", async () => {
|
||||
const saveConnections = vi.fn().mockResolvedValue(undefined);
|
||||
const store = await createStore(saveConnections);
|
||||
const connection = postgresConnection();
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "group-1", name: "Production", collapsed: false }],
|
||||
order: [{ type: "group", id: "group-1", children: [{ type: "connection", id: connection.id }] }],
|
||||
};
|
||||
const existingNode: TreeNode = {
|
||||
id: connection.id,
|
||||
label: connection.name,
|
||||
type: "connection",
|
||||
connectionId: connection.id,
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
store.connections = [connection];
|
||||
store.sidebarLayout = layout;
|
||||
store.treeNodes = [existingNode];
|
||||
store.connectedIds.add(connection.id);
|
||||
store.activeConnectionId = connection.id;
|
||||
store.selectedTreeNodeId = connection.id;
|
||||
store.selectedTreeNodeIds = [connection.id];
|
||||
|
||||
await expect(store.renameConnection(connection.id, " Reporting EU ")).resolves.toBe(true);
|
||||
|
||||
expect(saveConnections).toHaveBeenCalledWith([{ ...connection, name: "Reporting EU" }]);
|
||||
expect(store.getConfig(connection.id)).toEqual({ ...connection, name: "Reporting EU" });
|
||||
expect(store.connectedIds.has(connection.id)).toBe(true);
|
||||
expect(store.activeConnectionId).toBe(connection.id);
|
||||
expect(store.selectedTreeNodeId).toBe(connection.id);
|
||||
expect(store.selectedTreeNodeIds).toEqual([connection.id]);
|
||||
expect(store.sidebarLayout).toStrictEqual(layout);
|
||||
const renamedNode = store.treeNodes[0]?.children?.find((node) => node.id === connection.id);
|
||||
expect(renamedNode).toEqual(expect.objectContaining({ id: connection.id, label: "Reporting EU", isExpanded: true }));
|
||||
});
|
||||
|
||||
it("does not mutate the connection when persistence fails", async () => {
|
||||
const saveConnections = vi.fn().mockRejectedValue(new Error("disk full"));
|
||||
const store = await createStore(saveConnections);
|
||||
const connection = postgresConnection();
|
||||
store.connections = [connection];
|
||||
store.connectedIds.add(connection.id);
|
||||
|
||||
await expect(store.renameConnection(connection.id, "Renamed")).rejects.toThrow("disk full");
|
||||
|
||||
expect(store.getConfig(connection.id)).toEqual(connection);
|
||||
expect(store.connectedIds.has(connection.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores blank, unchanged, and missing connection names", async () => {
|
||||
const saveConnections = vi.fn().mockResolvedValue(undefined);
|
||||
const store = await createStore(saveConnections);
|
||||
const connection = postgresConnection();
|
||||
store.connections = [connection];
|
||||
|
||||
await expect(store.renameConnection(connection.id, " ")).resolves.toBe(false);
|
||||
await expect(store.renameConnection(connection.id, ` ${connection.name} `)).resolves.toBe(false);
|
||||
await expect(store.renameConnection("missing", "Renamed")).resolves.toBe(false);
|
||||
|
||||
expect(saveConnections).not.toHaveBeenCalled();
|
||||
expect(store.getConfig(connection.id)).toEqual(connection);
|
||||
});
|
||||
});
|
||||
@@ -2996,6 +2996,20 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function renameConnection(connectionId: string, name: string): Promise<boolean> {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return false;
|
||||
const index = connections.value.findIndex((connection) => connection.id === connectionId);
|
||||
if (index < 0 || connections.value[index].name === trimmed) return false;
|
||||
|
||||
const nextConnections = [...connections.value];
|
||||
nextConnections[index] = { ...nextConnections[index], name: trimmed };
|
||||
await persistConnections(nextConnections);
|
||||
connections.value = nextConnections;
|
||||
rebuildTreeNodes();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise<void> {
|
||||
const normalized = normalizeDatabaseConnectionInfo(databaseInfo);
|
||||
if (!normalized) return;
|
||||
@@ -8400,6 +8414,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
pasteConnectionClipboard,
|
||||
addEphemeralConnection,
|
||||
updateConnection,
|
||||
renameConnection,
|
||||
applyGlobalTimeouts,
|
||||
updateConnectionDatabaseInfo,
|
||||
setDefaultDatabase,
|
||||
|
||||
Reference in new issue
Block a user