feat(connection): choose group when creating a connection

This commit is contained in:
zipg authored and GitHub committed 2026-08-28 11:11:00 +08:00
1 parent 4636788f49
commit d8ec9bb8d0
13 files changed
+230 -39

No files matched your search

@@ -260,6 +260,20 @@ const emit = defineEmits<{
}>();
const store = useConnectionStore();
const UNGROUPED_CONNECTION_GROUP = "__dbx_ungrouped_connection_group__";
const selectedConnectionGroupId = ref<string | null>(null);
const connectionGroupSelectValue = computed({
get: () => selectedConnectionGroupId.value ?? UNGROUPED_CONNECTION_GROUP,
set: (value: string) => {
selectedConnectionGroupId.value = value === UNGROUPED_CONNECTION_GROUP ? null : value;
},
});
const connectionGroupOptions = computed(() => store.connectionGroupOptions.map((group) => ({ id: group.id, label: group.path.join(" / ") })));
function initialConnectionGroupId(): string | null {
const preferred = store.newConnectionGroupId ?? store.selectedConnectionGroupId;
return preferred && connectionGroupOptions.value.some((group) => group.id === preferred) ? preferred : null;
}
const tunnelProfileStore = useTunnelProfileStore();
const isTesting = ref(false);
const isSaving = ref(false);
@@ -2582,6 +2596,7 @@ watch(
} else {
clearSavedDatabaseInfo();
editingId.value = null;
selectedConnectionGroupId.value = initialConnectionGroupId();
form.value = defaultForm();
redisKeyTemplatesText.value = "";
productionProtectionEnabled.value = false;
@@ -4818,6 +4833,7 @@ function openJdbcDriverManagerFromError() {
function resetForm(options: { preservePickerState?: boolean } = {}) {
editingId.value = null;
selectedConnectionGroupId.value = initialConnectionGroupId();
form.value = defaultForm();
redisKeyTemplatesText.value = "";
resetConnectionNoteVisibilityDraft(connectionNoteVisibilityDraft, settingsStore.editorSettings.sidebarShowConnectionNotes);
@@ -4976,6 +4992,10 @@ watch(
{ immediate: true },
);
watch(connectionGroupOptions, (groups) => {
if (selectedConnectionGroupId.value && !groups.some((group) => group.id === selectedConnectionGroupId.value)) selectedConnectionGroupId.value = null;
});
watch(
() => props.prefillConfig,
(draft) => {
@@ -5231,7 +5251,7 @@ async function save() {
await ensureRequiredAgentDriverInstalled(config);
await ensureRequiredGaussdbMJdbcRuntime(config);
await persistGlobalTimeoutDrafts();
await store.addConnection(config);
await store.addConnection(config, selectedConnectionGroupId.value);
connectionSaved = true;
await persistConnectionNoteVisibilityDraft();
draftTestConnectionId.value = uuid();
@@ -5846,35 +5866,49 @@ function openExternalUrl(url: string) {
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.color") }}</Label>
<div class="col-span-3 flex items-center gap-1.5">
<button
v-for="color in colorOptions"
:key="color.value || 'none'"
type="button"
class="h-6 w-6 rounded-full border ring-offset-background transition hover:scale-105"
:class="[color.class, form.color === color.value ? 'ring-2 ring-ring ring-offset-2' : 'border-border']"
:title="t(color.labelKey)"
@click="handlePresetClick(color.value)"
/>
<Popover v-model:open="customColorOpen">
<PopoverTrigger as-child>
<button
type="button"
class="h-6 w-6 rounded-full border flex items-center justify-center hover:scale-105 transition"
:class="[!isPresetColor(form.color) && form.color ? 'border-border ring-2 ring-ring ring-offset-2' : 'border-dashed border-border']"
:style="!isPresetColor(form.color) && form.color ? { backgroundColor: form.color } : {}"
:title="t('connection.colorCustom')"
>
<Pipette class="h-3.5 w-3.5" :class="!isPresetColor(form.color) && form.color ? 'text-white' : 'text-muted-foreground'" />
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-2">
<div class="flex items-center gap-2">
<input type="color" :value="form.color" @input="handleCustomColorPicked(($event.target as HTMLInputElement).value)" class="h-6 w-6 cursor-pointer rounded border-0 p-0" />
<Input type="text" :value="customColorInput || form.color" @input="handleCustomColorInput(($event.target as HTMLInputElement).value)" class="w-28 h-7 text-xs font-mono" :placeholder="t('connection.customColorPlaceholder')" />
</div>
</PopoverContent>
</Popover>
<div class="col-span-3 flex min-w-0 items-center">
<div class="flex shrink-0 items-center gap-1.5">
<button
v-for="color in colorOptions"
:key="color.value || 'none'"
type="button"
class="h-6 w-6 rounded-full border ring-offset-background transition hover:scale-105"
:class="[color.class, form.color === color.value ? 'ring-2 ring-ring ring-offset-2' : 'border-border']"
:title="t(color.labelKey)"
@click="handlePresetClick(color.value)"
/>
<Popover v-model:open="customColorOpen">
<PopoverTrigger as-child>
<button
type="button"
class="h-6 w-6 rounded-full border flex items-center justify-center hover:scale-105 transition"
:class="[!isPresetColor(form.color) && form.color ? 'border-border ring-2 ring-ring ring-offset-2' : 'border-dashed border-border']"
:style="!isPresetColor(form.color) && form.color ? { backgroundColor: form.color } : {}"
:title="t('connection.colorCustom')"
>
<Pipette class="h-3.5 w-3.5" :class="!isPresetColor(form.color) && form.color ? 'text-white' : 'text-muted-foreground'" />
</button>
</PopoverTrigger>
<PopoverContent class="w-auto p-2">
<div class="flex items-center gap-2">
<input type="color" :value="form.color" @input="handleCustomColorPicked(($event.target as HTMLInputElement).value)" class="h-6 w-6 cursor-pointer rounded border-0 p-0" />
<Input type="text" :value="customColorInput || form.color" @input="handleCustomColorInput(($event.target as HTMLInputElement).value)" class="w-28 h-7 text-xs font-mono" :placeholder="t('connection.customColorPlaceholder')" />
</div>
</PopoverContent>
</Popover>
</div>
<div v-if="!editingId && connectionGroupOptions.length > 0" class="ml-3 flex min-w-0 flex-1 items-center gap-2 border-l pl-3">
<Label class="shrink-0 text-xs text-muted-foreground">{{ t("connectionGroup.group") }}</Label>
<Select v-model="connectionGroupSelectValue">
<SelectTrigger class="h-8 min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem :value="UNGROUPED_CONNECTION_GROUP">{{ t("connectionGroup.ungroupedLabel") }}</SelectItem>
<SelectItem v-for="group in connectionGroupOptions" :key="group.id" :value="group.id">{{ group.label }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
+2 -1
View File
@@ -2348,6 +2348,7 @@ export default {
},
connectionGroup: {
createGroup: "New Group",
group: "Group",
groupNamePlaceholder: "Group name",
selectConnection: "Select connection",
deselectConnection: "Deselect connection",
@@ -2362,7 +2363,7 @@ export default {
moveToGroup: "Move to Group",
moveToNewGroup: "Move to New Group",
ungrouped: "Ungrouped",
ungroupedLabel: "Ungrouped",
ungroupedLabel: "Default",
newGroup: "New Group...",
newGroupDefault: "New Group",
deleteGroupConfirmTitle: "Delete Group",
+2 -1
View File
@@ -2283,6 +2283,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "Nuevo grupo",
group: "Grupo",
groupNamePlaceholder: "Nombre del grupo",
renameGroup: "Renombrar grupo",
closeConnections: "Cerrar conexiones del grupo ({count})",
@@ -2291,7 +2292,7 @@ export default withEnglishFallback({
moveToGroup: "Mover al grupo",
moveToNewGroup: "Mover a nuevo grupo",
ungrouped: "Sin grupo",
ungroupedLabel: "Sin grupo",
ungroupedLabel: "Predeterminado",
newGroup: "Nuevo grupo...",
newGroupDefault: "Nuevo grupo",
deleteGroupConfirmTitle: "Eliminar grupo",
+2 -1
View File
@@ -2281,6 +2281,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "Nuovo Gruppo",
group: "Gruppo",
groupNamePlaceholder: "Nome gruppo",
renameGroup: "Rinomina Gruppo",
closeConnections: "Chiudi le connessioni del gruppo ({count})",
@@ -2289,7 +2290,7 @@ export default withEnglishFallback({
moveToGroup: "Sposta nel Gruppo",
moveToNewGroup: "Sposta in un Nuovo Gruppo",
ungrouped: "Non raggruppati",
ungroupedLabel: "Non raggruppati",
ungroupedLabel: "Predefinito",
newGroup: "Nuovo Gruppo...",
newGroupDefault: "Nuovo Gruppo",
deleteGroupConfirmTitle: "Elimina Gruppo",
+2 -1
View File
@@ -2308,6 +2308,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "新しいグループ",
group: "グループ",
groupNamePlaceholder: "グループ名",
renameGroup: "グループ名を変更",
closeConnections: "グループ内の接続を閉じる({count})",
@@ -2316,7 +2317,7 @@ export default withEnglishFallback({
moveToGroup: "グループに移動",
moveToNewGroup: "新しいグループに移動",
ungrouped: "グループなし",
ungroupedLabel: "グループなし",
ungroupedLabel: "デフォルト",
newGroup: "新しいグループ...",
newGroupDefault: "新しいグループ",
deleteGroupConfirmTitle: "グループを削除",
+2 -1
View File
@@ -2182,6 +2182,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "새 그룹",
group: "그룹",
groupNamePlaceholder: "그룹 이름",
selectConnection: "연결 선택",
deselectConnection: "연결 선택 해제",
@@ -2196,7 +2197,7 @@ export default withEnglishFallback({
moveToGroup: "그룹으로 이동",
moveToNewGroup: "새 그룹으로 이동",
ungrouped: "그룹 없음",
ungroupedLabel: "그룹 없음",
ungroupedLabel: "기본",
newGroup: "새 그룹...",
newGroupDefault: "새 그룹",
deleteGroupConfirmTitle: "그룹 삭제",
+2 -1
View File
@@ -2283,6 +2283,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "Novo Grupo",
group: "Grupo",
groupNamePlaceholder: "Nome do grupo",
renameGroup: "Renomear Grupo",
closeConnections: "Fechar conexões do grupo ({count})",
@@ -2291,7 +2292,7 @@ export default withEnglishFallback({
moveToGroup: "Mover para Grupo",
moveToNewGroup: "Mover para Novo Grupo",
ungrouped: "Sem grupo",
ungroupedLabel: "Sem grupo",
ungroupedLabel: "Padrão",
newGroup: "Novo Grupo...",
newGroupDefault: "Novo Grupo",
deleteGroupConfirmTitle: "Excluir Grupo",
+2 -1
View File
@@ -2272,6 +2272,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "新建分组",
group: "分组",
groupNamePlaceholder: "分组名称",
selectConnection: "选择连接",
deselectConnection: "取消选择连接",
@@ -2286,7 +2287,7 @@ export default withEnglishFallback({
moveToGroup: "移至分组",
moveToNewGroup: "移至新分组",
ungrouped: "取消分组",
ungroupedLabel: "未分组",
ungroupedLabel: "默认",
newGroup: "新建分组...",
newGroupDefault: "新分组",
deleteGroupConfirmTitle: "删除分组",
+2 -1
View File
@@ -2282,6 +2282,7 @@ export default withEnglishFallback({
},
connectionGroup: {
createGroup: "建立群組",
group: "群組",
groupNamePlaceholder: "群組名稱",
renameGroup: "重新命名群組",
closeConnections: "關閉群組內連線({count})",
@@ -2290,7 +2291,7 @@ export default withEnglishFallback({
moveToGroup: "移至群組",
moveToNewGroup: "移至新群組",
ungrouped: "取消群組",
ungroupedLabel: "未分組",
ungroupedLabel: "預設",
newGroup: "新增群組……",
newGroupDefault: "新群組",
deleteGroupConfirmTitle: "刪除群組",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { connectionIdsInGroups, deleteGroups, findConnectionGroupPath, reorderEntries } from "@/lib/sidebar/sidebarLayout";
import { connectionGroupDestinationRows, connectionGroupIdForSelection, connectionIdsInGroups, deleteGroups, findConnectionGroupPath, reorderEntries } from "@/lib/sidebar/sidebarLayout";
import type { SidebarLayout } from "@/types/database";
const layout: SidebarLayout = {
@@ -203,3 +203,24 @@ describe("sorted sidebar move-only drops", () => {
});
});
});
describe("connection group selection", () => {
it("lists nested groups in sidebar order with complete paths", () => {
expect(connectionGroupDestinationRows(layout)).toEqual([
{ id: "project", name: "Project", depth: 0, path: ["Project"] },
{ id: "staging", name: "Staging", depth: 1, path: ["Project", "Staging"] },
]);
});
it("uses the focused group or the group containing a focused connection", () => {
expect(connectionGroupIdForSelection(layout, "staging")).toBe("staging");
expect(connectionGroupIdForSelection(layout, "nested", "nested")).toBe("staging");
expect(connectionGroupIdForSelection(layout, "grouped")).toBe("project");
});
it("falls back to ungrouped for root or unknown selections", () => {
expect(connectionGroupIdForSelection(layout, "root", "root")).toBeNull();
expect(connectionGroupIdForSelection(layout, "missing")).toBeNull();
expect(connectionGroupIdForSelection(layout)).toBeNull();
});
});
@@ -367,6 +367,14 @@ export function connectionGroupDestinationRows(layout: SidebarLayout): Connectio
return rows;
}
/** Resolve the group implied by a selected group, connection, or descendant node. */
export function connectionGroupIdForSelection(layout: SidebarLayout, selectedNodeId?: string | null, selectedConnectionId?: string | null): string | null {
if (selectedNodeId && connectionGroupDestinationRows(layout).some((group) => group.id === selectedNodeId)) return selectedNodeId;
const connectionId = selectedConnectionId || selectedNodeId;
if (!connectionId) return null;
return findConnectionLocation(layout, connectionId)?.groupId ?? null;
}
function findGroupEntry(entries: SidebarOrderEntry[], groupId: string): Extract<SidebarOrderEntry, { type: "group" }> | null {
for (const entry of entries) {
if (entry.type !== "group") continue;
@@ -0,0 +1,110 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
function connection(id: string): ConnectionConfig {
return {
id,
name: id,
db_type: "mysql",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "",
connect_timeout_secs: 10,
query_timeout_secs: 30,
connect_timeout_inherit: false,
query_timeout_inherit: false,
};
}
const groupedLayout: SidebarLayout = {
groups: [
{ id: "parent", name: "Parent", collapsed: false },
{ id: "child", name: "Child", collapsed: false },
],
order: [
{
type: "group",
id: "parent",
children: [{ type: "group", id: "child", children: [{ type: "connection", id: "existing" }] }],
},
],
};
describe("connectionStore creating connections in groups", () => {
beforeEach(() => {
vi.resetModules();
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
});
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
loadEditorSettings: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveEditorSettings: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
setActivePinia(createPinia());
});
it("places a new connection in the explicitly selected nested group", async () => {
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.sidebarLayout = structuredClone(groupedLayout);
await store.addConnection(connection("created"), "child");
expect(store.groupIdForConnection("created")).toBe("child");
expect(store.connectionGroupPaths.get("created")).toEqual(["Parent", "Child"]);
});
it("places a new connection at the top level when ungrouped is selected", async () => {
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.sidebarLayout = structuredClone(groupedLayout);
store.startCreatingConnectionInGroup("child");
await store.addConnection(connection("created"), null);
expect(store.groupIdForConnection("created")).toBeNull();
expect(store.sidebarLayout.order.at(-1)).toEqual({ type: "connection", id: "created" });
expect(store.newConnectionGroupId).toBeNull();
});
it("derives the default group from a selected descendant node", async () => {
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
store.sidebarLayout = structuredClone(groupedLayout);
store.addEphemeralConnection(connection("existing"));
store.treeNodes = [
{
id: "parent",
label: "Parent",
type: "connection-group",
children: [
{
id: "child",
label: "Child",
type: "connection-group",
children: [
{
id: "existing",
label: "existing",
type: "connection",
connectionId: "existing",
children: [{ id: "existing:demo", label: "demo", type: "database", connectionId: "existing" }],
},
],
},
],
},
];
store.selectedTreeNodeId = "existing:demo";
expect(store.selectedConnectionGroupId).toBe("child");
});
});
@@ -58,6 +58,8 @@ import {
reorderEntry as reorderEntryOp,
reorderEntries as reorderEntriesOp,
buildConnectionGroupPathMap,
connectionGroupDestinationRows,
connectionGroupIdForSelection,
connectionSidebarSearchAliases,
type DropPosition,
type ReorderEntriesOptions,
@@ -557,6 +559,12 @@ export const useConnectionStore = defineStore("connection", () => {
} | null>(null);
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
const connectionGroupPaths = computed(() => buildConnectionGroupPathMap(sidebarLayout.value));
const connectionGroupOptions = computed(() => connectionGroupDestinationRows(sidebarLayout.value));
const selectedConnectionGroupId = computed(() => {
const selectedNodeId = selectedTreeNodeId.value;
const selectedNode = selectedNodeId ? findNode(treeNodes.value, selectedNodeId) : null;
return connectionGroupIdForSelection(sidebarLayout.value, selectedNodeId, selectedNode?.connectionId);
});
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
const staleTreeRefreshIds = new Set<string>();
const activeTreeRefreshGenerations = new Map<string, number>();
@@ -8749,6 +8757,8 @@ export const useConnectionStore = defineStore("connection", () => {
recordConnectionLostError,
sidebarLayout,
connectionGroupPaths,
connectionGroupOptions,
selectedConnectionGroupId,
getConfig,
connectionIdentifierQuote,
isTreeNodePinned,