fix(sidebar): clear selection after connection moves
This commit is contained in:
1 parent
8d084cc655
commit
c6f9ddb5a4
8 files changed
+576
-23
No files matched your search
@@ -0,0 +1,261 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
store: null as any,
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => mocks.store,
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { disabled: Boolean },
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h("button", { ...attrs, disabled: props.disabled }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = (name: string) =>
|
||||
defineComponent({
|
||||
name,
|
||||
setup(_, { slots }) {
|
||||
return () => h("div", { [`data-${name.toLowerCase()}`]: "" }, slots.default?.());
|
||||
},
|
||||
});
|
||||
return {
|
||||
Dialog: defineComponent({
|
||||
props: { open: Boolean },
|
||||
emits: ["update:open"],
|
||||
setup(props, { slots }) {
|
||||
return () => (props.open ? h("div", { "data-dialog": "" }, slots.default?.()) : null);
|
||||
},
|
||||
}),
|
||||
DialogContent: passthrough("DialogContent"),
|
||||
DialogFooter: passthrough("DialogFooter"),
|
||||
DialogHeader: passthrough("DialogHeader"),
|
||||
DialogTitle: passthrough("DialogTitle"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/input", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Input: defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: { modelValue: { type: String, default: "" } },
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { attrs, emit }) {
|
||||
return () =>
|
||||
h("input", {
|
||||
...attrs,
|
||||
value: props.modelValue,
|
||||
onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value),
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/LightTooltip.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: { text: { type: String, default: "" } },
|
||||
setup(props, { slots }) {
|
||||
return () => h("span", { "data-tooltip": props.text }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/LightDropdown.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: {
|
||||
items: { type: Array, default: () => [] },
|
||||
ariaLabel: { type: String, default: "" },
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h(
|
||||
"div",
|
||||
{ "data-dropdown": props.ariaLabel },
|
||||
(props.items as Array<{ value?: string; val?: string; label: string }>).map((item) => {
|
||||
const value = item.value ?? item.val ?? "";
|
||||
return h("button", { "data-dropdown-value": value, onClick: () => emit("update:modelValue", value) }, item.label);
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/sidebar/ConnectionTree.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
setup(_, { expose }) {
|
||||
expose({
|
||||
createNewGroup: vi.fn(),
|
||||
collapseAllTreeNodes: vi.fn(),
|
||||
focusSearch: vi.fn(() => false),
|
||||
});
|
||||
return () => h("div", { "data-connection-tree": "" });
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||
|
||||
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
function createStore() {
|
||||
return reactive({
|
||||
connections: [{ id: "conn-visible" }, { id: "conn-hidden" }, { id: "conn-next" }],
|
||||
sidebarLayout: { groups: [{ id: "group-a", name: "Group A" }] },
|
||||
selectedTreeNodeIds: [] as string[],
|
||||
selectedTreeNodeId: null as string | null,
|
||||
treeSelectionAnchorId: null as string | null,
|
||||
connectionMultiSelectActive: false,
|
||||
moveConnectionToGroup: vi.fn(),
|
||||
createConnectionGroup: vi.fn(() => "group-new"),
|
||||
refreshAllTree: vi.fn().mockResolvedValue(undefined),
|
||||
removeConnections: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
}
|
||||
|
||||
function setSelection(connectionIds: string[]) {
|
||||
const lastConnectionId = connectionIds[connectionIds.length - 1] ?? null;
|
||||
mocks.store.selectedTreeNodeIds = [...connectionIds];
|
||||
mocks.store.selectedTreeNodeId = lastConnectionId;
|
||||
mocks.store.treeSelectionAnchorId = lastConnectionId;
|
||||
mocks.store.connectionMultiSelectActive = connectionIds.length > 0;
|
||||
}
|
||||
|
||||
function click(element: Element | null) {
|
||||
expect(element).not.toBeNull();
|
||||
element!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
|
||||
async function mountSidebar() {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () => h(AppSidebar, { sidebarWidth: 260 }),
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
mountedApps.push({ unmount: () => app.unmount(), host });
|
||||
await nextTick();
|
||||
return host;
|
||||
}
|
||||
|
||||
describe("AppSidebar connection multi-select moves", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.store = createStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const mounted of mountedApps.splice(0)) {
|
||||
mounted.unmount();
|
||||
mounted.host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases an existing-group batch including filtered connections before the next batch", async () => {
|
||||
setSelection(["conn-visible", "conn-hidden"]);
|
||||
const host = await mountSidebar();
|
||||
|
||||
click(host.querySelector('[data-dropdown="connectionGroup.moveToGroup"] [data-dropdown-value="group-a"]'));
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.store.moveConnectionToGroup.mock.calls).toEqual([
|
||||
["conn-visible", "group-a"],
|
||||
["conn-hidden", "group-a"],
|
||||
]);
|
||||
expect(mocks.store.selectedTreeNodeIds).toEqual([]);
|
||||
expect(mocks.store.connectionMultiSelectActive).toBe(false);
|
||||
|
||||
setSelection(["conn-next"]);
|
||||
await nextTick();
|
||||
click(host.querySelector('[data-dropdown="connectionGroup.moveToGroup"] [data-dropdown-value="group-a"]'));
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.store.moveConnectionToGroup.mock.calls[mocks.store.moveConnectionToGroup.mock.calls.length - 1]).toEqual(["conn-next", "group-a"]);
|
||||
expect(mocks.store.moveConnectionToGroup).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.store.selectedTreeNodeIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps selection on cancel and releases it after creating a new group", async () => {
|
||||
setSelection(["conn-visible", "conn-hidden"]);
|
||||
const host = await mountSidebar();
|
||||
|
||||
click(host.querySelector('[data-tooltip="connectionGroup.createGroup"] button'));
|
||||
await nextTick();
|
||||
click(Array.from(host.querySelectorAll("[data-dialog] button")).find((button) => button.textContent === "dangerDialog.cancel") ?? null);
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.store.createConnectionGroup).not.toHaveBeenCalled();
|
||||
expect(mocks.store.moveConnectionToGroup).not.toHaveBeenCalled();
|
||||
expect(mocks.store.selectedTreeNodeIds).toEqual(["conn-visible", "conn-hidden"]);
|
||||
|
||||
click(host.querySelector('[data-tooltip="connectionGroup.createGroup"] button'));
|
||||
await nextTick();
|
||||
const input = host.querySelector<HTMLInputElement>("[data-dialog] input");
|
||||
expect(input).not.toBeNull();
|
||||
input!.value = "New group";
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
click(Array.from(host.querySelectorAll("[data-dialog] button")).find((button) => button.textContent === "connectionGroup.createGroup") ?? null);
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.store.createConnectionGroup).toHaveBeenCalledWith("New group");
|
||||
expect(mocks.store.moveConnectionToGroup.mock.calls).toEqual([
|
||||
["conn-visible", "group-new"],
|
||||
["conn-hidden", "group-new"],
|
||||
]);
|
||||
expect(mocks.store.selectedTreeNodeIds).toEqual([]);
|
||||
expect(mocks.store.connectionMultiSelectActive).toBe(false);
|
||||
});
|
||||
|
||||
it("releases a batch moved to ungrouped", async () => {
|
||||
setSelection(["conn-visible", "conn-hidden"]);
|
||||
const host = await mountSidebar();
|
||||
|
||||
click(host.querySelector('[data-dropdown="connectionGroup.moveToGroup"] [data-dropdown-value="__ungrouped"]'));
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.store.moveConnectionToGroup.mock.calls).toEqual([
|
||||
["conn-visible", null],
|
||||
["conn-hidden", null],
|
||||
]);
|
||||
expect(mocks.store.selectedTreeNodeIds).toEqual([]);
|
||||
expect(mocks.store.connectionMultiSelectActive).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||
import { applyConnectionMultiSelection, emptyConnectionMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
|
||||
@@ -86,10 +87,7 @@ function focusSearch(): boolean {
|
||||
}
|
||||
|
||||
function clearConnectionMultiSelection() {
|
||||
connectionStore.connectionMultiSelectActive = false;
|
||||
connectionStore.selectedTreeNodeIds = [];
|
||||
connectionStore.selectedTreeNodeId = null;
|
||||
connectionStore.treeSelectionAnchorId = null;
|
||||
applyConnectionMultiSelection(connectionStore, emptyConnectionMultiSelection());
|
||||
}
|
||||
|
||||
function toggleAllConnectionsSelected() {
|
||||
@@ -125,9 +123,13 @@ async function confirmDeleteSelectedConnections() {
|
||||
|
||||
function moveSelectedConnectionsToGroup(value: string) {
|
||||
const groupId = value === UNGROUPED_GROUP_VALUE ? null : value;
|
||||
for (const connectionId of selectedConnectionIds.value) {
|
||||
const ids = selectedConnectionIds.value;
|
||||
for (const connectionId of ids) {
|
||||
connectionStore.moveConnectionToGroup(connectionId, groupId);
|
||||
}
|
||||
// The moved connections stay selected otherwise, so the next batch would be
|
||||
// moved together with them (issue #5758).
|
||||
clearConnectionMultiSelection();
|
||||
}
|
||||
|
||||
function openCreateSelectedGroupDialog() {
|
||||
@@ -137,11 +139,13 @@ function openCreateSelectedGroupDialog() {
|
||||
|
||||
function confirmCreateSelectedGroup() {
|
||||
const name = selectedGroupName.value.trim();
|
||||
if (!name || selectedConnectionIds.value.length === 0) return;
|
||||
const ids = selectedConnectionIds.value;
|
||||
if (!name || ids.length === 0) return;
|
||||
const groupId = connectionStore.createConnectionGroup(name);
|
||||
for (const connectionId of selectedConnectionIds.value) {
|
||||
for (const connectionId of ids) {
|
||||
connectionStore.moveConnectionToGroup(connectionId, groupId);
|
||||
}
|
||||
clearConnectionMultiSelection();
|
||||
showCreateSelectedGroupDialog.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ const {
|
||||
newSubgroup,
|
||||
confirmDeleteGroup,
|
||||
moveToGroup,
|
||||
createGroupAndMoveConnection,
|
||||
} = useSidebarConnectionMutationRuntime({
|
||||
activeNode,
|
||||
releaseActiveNodeReference,
|
||||
@@ -3799,12 +3800,7 @@ function moveToNewGroup() {
|
||||
}
|
||||
|
||||
function confirmMoveToNewGroup() {
|
||||
const name = moveToNewGroupName.value.trim();
|
||||
const node = sidebarFormTarget.value ?? activeNode.value;
|
||||
if (name && node.connectionId) {
|
||||
const groupId = connectionStore.createConnectionGroup(name);
|
||||
connectionStore.moveConnectionToGroup(node.connectionId, groupId);
|
||||
}
|
||||
createGroupAndMoveConnection(moveToNewGroupName.value);
|
||||
showMoveToNewGroupDialog.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ import { hexToRgba } from "@/lib/common/color";
|
||||
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip";
|
||||
import { treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
|
||||
import { applyConnectionMultiSelection, connectionMultiSelectionAfterToggle } from "@/lib/sidebar/sidebarConnectionMultiSelect";
|
||||
import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState";
|
||||
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
||||
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
|
||||
@@ -568,15 +569,8 @@ function toggleConnectionMultiSelection(event: MouseEvent) {
|
||||
|
||||
// Keep connection-id normalization off the row render path; this handler only
|
||||
// runs when the checkbox is clicked, while the checked state updates often.
|
||||
const next = new Set(connectionStore.connectionMultiSelectActive ? selectedConnectionIdsForAction() : []);
|
||||
if (next.has(activeNode.value.connectionId)) next.delete(activeNode.value.connectionId);
|
||||
else next.add(activeNode.value.connectionId);
|
||||
|
||||
const ids = [...next];
|
||||
connectionStore.selectedTreeNodeIds = ids;
|
||||
connectionStore.selectedTreeNodeId = ids.includes(activeNode.value.connectionId) ? activeNode.value.connectionId : (ids[0] ?? null);
|
||||
connectionStore.treeSelectionAnchorId = activeNode.value.connectionId;
|
||||
connectionStore.connectionMultiSelectActive = ids.length > 0;
|
||||
const current = { connectionIds: selectedConnectionIdsForAction(), active: connectionStore.connectionMultiSelectActive };
|
||||
applyConnectionMultiSelection(connectionStore, connectionMultiSelectionAfterToggle(current, activeNode.value.connectionId));
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
|
||||
Vendored
+129
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { shallowRef } from "vue";
|
||||
import { sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({
|
||||
useToast: () => ({ toast: mocks.toast }),
|
||||
}));
|
||||
|
||||
import { useSidebarConnectionMutationRuntime } from "@/composables/useSidebarConnectionMutationRuntime";
|
||||
|
||||
function connectionNode(connectionId: string): TreeNode {
|
||||
return {
|
||||
id: connectionId,
|
||||
label: connectionId,
|
||||
type: "connection",
|
||||
connectionId,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function connectionStore(selectedTreeNodeIds: string[]) {
|
||||
const lastSelectedTreeNodeId = selectedTreeNodeIds[selectedTreeNodeIds.length - 1] ?? null;
|
||||
return {
|
||||
selectedTreeNodeIds: [...selectedTreeNodeIds],
|
||||
selectedTreeNodeId: lastSelectedTreeNodeId,
|
||||
treeSelectionAnchorId: lastSelectedTreeNodeId,
|
||||
connectionMultiSelectActive: selectedTreeNodeIds.length > 0,
|
||||
moveConnectionToGroup: vi.fn(),
|
||||
createConnectionGroup: vi.fn(() => "group-new"),
|
||||
connectedIds: new Set<string>(),
|
||||
connectingIds: new Set<string>(),
|
||||
isTreeNodeChildrenLoaded: vi.fn(() => false),
|
||||
getConfig: vi.fn(() => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(activeNode: TreeNode, store: ReturnType<typeof connectionStore>) {
|
||||
return useSidebarConnectionMutationRuntime({
|
||||
activeNode: shallowRef(activeNode),
|
||||
releaseActiveNodeReference: vi.fn(),
|
||||
selectedTreeNodesInVisibleOrder: () => [],
|
||||
connectionStore: store as any,
|
||||
queryStore: { openDatabaseKeys: new Set<string>() } as any,
|
||||
requestGroupRename: vi.fn(),
|
||||
groupCreated: vi.fn(),
|
||||
openVisibleDatabases: vi.fn(),
|
||||
openVisibleSchemas: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("sidebar connection move selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sidebarFormTarget.value = null;
|
||||
});
|
||||
|
||||
it("removes only the right-clicked connection after moving to an existing group", () => {
|
||||
const store = connectionStore(["conn-hidden", "conn-active"]);
|
||||
const { moveToGroup } = runtime(connectionNode("conn-active"), store);
|
||||
|
||||
moveToGroup("group-a");
|
||||
|
||||
expect(store.moveConnectionToGroup).toHaveBeenCalledWith("conn-active", "group-a");
|
||||
expect(store.selectedTreeNodeIds).toEqual(["conn-hidden"]);
|
||||
expect(store.selectedTreeNodeId).toBe("conn-hidden");
|
||||
expect(store.treeSelectionAnchorId).toBe("conn-hidden");
|
||||
expect(store.connectionMultiSelectActive).toBe(true);
|
||||
});
|
||||
|
||||
it("releases the final selected connection after moving it to ungrouped", () => {
|
||||
const store = connectionStore(["conn-active"]);
|
||||
const { moveToGroup } = runtime(connectionNode("conn-active"), store);
|
||||
|
||||
moveToGroup(null);
|
||||
|
||||
expect(store.moveConnectionToGroup).toHaveBeenCalledWith("conn-active", null);
|
||||
expect(store.selectedTreeNodeIds).toEqual([]);
|
||||
expect(store.selectedTreeNodeId).toBeNull();
|
||||
expect(store.treeSelectionAnchorId).toBeNull();
|
||||
expect(store.connectionMultiSelectActive).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the dialog target when creating a group and preserves filtered selections", () => {
|
||||
const store = connectionStore(["conn-dialog", "conn-hidden"]);
|
||||
sidebarFormTarget.value = connectionNode("conn-dialog");
|
||||
const { createGroupAndMoveConnection } = runtime(connectionNode("conn-current"), store);
|
||||
|
||||
expect(createGroupAndMoveConnection(" New group ")).toBe(true);
|
||||
|
||||
expect(store.createConnectionGroup).toHaveBeenCalledWith("New group");
|
||||
expect(store.moveConnectionToGroup).toHaveBeenCalledWith("conn-dialog", "group-new");
|
||||
expect(store.selectedTreeNodeIds).toEqual(["conn-hidden"]);
|
||||
expect(store.connectionMultiSelectActive).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selection when a new-group move is cancelled", () => {
|
||||
const store = connectionStore(["conn-active", "conn-hidden"]);
|
||||
const { createGroupAndMoveConnection } = runtime(connectionNode("conn-active"), store);
|
||||
|
||||
expect(createGroupAndMoveConnection(" ")).toBe(false);
|
||||
|
||||
expect(store.createConnectionGroup).not.toHaveBeenCalled();
|
||||
expect(store.moveConnectionToGroup).not.toHaveBeenCalled();
|
||||
expect(store.selectedTreeNodeIds).toEqual(["conn-active", "conn-hidden"]);
|
||||
expect(store.connectionMultiSelectActive).toBe(true);
|
||||
});
|
||||
|
||||
it("does not release the connection when the move fails", () => {
|
||||
const store = connectionStore(["conn-active", "conn-hidden"]);
|
||||
store.moveConnectionToGroup.mockImplementation(() => {
|
||||
throw new Error("move failed");
|
||||
});
|
||||
const { moveToGroup } = runtime(connectionNode("conn-active"), store);
|
||||
|
||||
expect(() => moveToGroup("group-a")).toThrow("move failed");
|
||||
expect(store.selectedTreeNodeIds).toEqual(["conn-active", "conn-hidden"]);
|
||||
expect(store.connectionMultiSelectActive).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { revealPathInFileManager } from "@/lib/backend/tauri";
|
||||
import { canConfigureVisibleSchemasForTreeNode } from "@/lib/database/databaseFeatureSupport";
|
||||
import { canCloseSidebarDatabaseConnection } from "@/lib/sidebar/sidebarDatabaseOpenState";
|
||||
import { selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets } from "@/lib/sidebar/sidebarConnectionSelection";
|
||||
import { releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
|
||||
import { connectionDeleteTargetSnapshot, showDeleteConfirm, showDeleteGroupConfirm, sidebarFormTarget } from "@/components/sidebar/sidebarTreeDialogState";
|
||||
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
|
||||
|
||||
@@ -305,7 +306,19 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
|
||||
|
||||
function moveToGroup(groupId: string | null) {
|
||||
const connectionId = activeNode.value.connectionId;
|
||||
if (connectionId) connectionStore.moveConnectionToGroup(connectionId, groupId);
|
||||
if (!connectionId) return;
|
||||
connectionStore.moveConnectionToGroup(connectionId, groupId);
|
||||
releaseConnectionFromMultiSelection(connectionStore, connectionId);
|
||||
}
|
||||
|
||||
function createGroupAndMoveConnection(name: string): boolean {
|
||||
const node = sidebarFormTarget.value ?? activeNode.value;
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName || !node.connectionId) return false;
|
||||
const groupId = connectionStore.createConnectionGroup(normalizedName);
|
||||
connectionStore.moveConnectionToGroup(node.connectionId, groupId);
|
||||
releaseConnectionFromMultiSelection(connectionStore, node.connectionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -349,5 +362,6 @@ export function useSidebarConnectionMutationRuntime(options: SidebarConnectionMu
|
||||
newSubgroup,
|
||||
confirmDeleteGroup,
|
||||
moveToGroup,
|
||||
createGroupAndMoveConnection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyConnectionMultiSelection, connectionMultiSelectionAfterToggle, emptyConnectionMultiSelection, releaseConnectionFromMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
|
||||
import type { ConnectionMultiSelection, ConnectionMultiSelectionTarget } from "@/lib/sidebar/sidebarConnectionMultiSelect";
|
||||
|
||||
function tick(selection: ConnectionMultiSelection, ...connectionIds: string[]): ConnectionMultiSelection {
|
||||
return connectionIds.reduce(connectionMultiSelectionAfterToggle, selection);
|
||||
}
|
||||
|
||||
function target(): ConnectionMultiSelectionTarget {
|
||||
return { selectedTreeNodeIds: [], selectedTreeNodeId: null, treeSelectionAnchorId: null, connectionMultiSelectActive: false };
|
||||
}
|
||||
|
||||
describe("connection multi-selection", () => {
|
||||
it("adds each ticked connection to the running selection", () => {
|
||||
const selection = tick(emptyConnectionMultiSelection(), "conn-1", "conn-2", "conn-3");
|
||||
|
||||
expect(selection.connectionIds).toEqual(["conn-1", "conn-2", "conn-3"]);
|
||||
expect(selection.activeConnectionId).toBe("conn-3");
|
||||
expect(selection.anchorConnectionId).toBe("conn-3");
|
||||
expect(selection.active).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a connection that is ticked twice", () => {
|
||||
const selection = tick(emptyConnectionMultiSelection(), "conn-1", "conn-2", "conn-2");
|
||||
|
||||
expect(selection.connectionIds).toEqual(["conn-1"]);
|
||||
expect(selection.activeConnectionId).toBe("conn-1");
|
||||
expect(selection.active).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves multi-select off once the last connection is unticked", () => {
|
||||
const selection = tick(emptyConnectionMultiSelection(), "conn-1", "conn-1");
|
||||
|
||||
expect(selection.connectionIds).toEqual([]);
|
||||
expect(selection.activeConnectionId).toBeNull();
|
||||
expect(selection.active).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the selection back to the tree selection fields", () => {
|
||||
const store = target();
|
||||
|
||||
applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1", "conn-2"));
|
||||
|
||||
expect(store).toEqual({
|
||||
selectedTreeNodeIds: ["conn-1", "conn-2"],
|
||||
selectedTreeNodeId: "conn-2",
|
||||
treeSelectionAnchorId: "conn-2",
|
||||
connectionMultiSelectActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears every tree selection field when the selection is released", () => {
|
||||
const store = target();
|
||||
applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1"));
|
||||
|
||||
applyConnectionMultiSelection(store, emptyConnectionMultiSelection());
|
||||
|
||||
expect(store).toEqual({
|
||||
selectedTreeNodeIds: [],
|
||||
selectedTreeNodeId: null,
|
||||
treeSelectionAnchorId: null,
|
||||
connectionMultiSelectActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("releases only the connection that was moved from a multi-selection", () => {
|
||||
const store = target();
|
||||
applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1", "conn-2", "conn-3"));
|
||||
|
||||
releaseConnectionFromMultiSelection(store, "conn-3");
|
||||
|
||||
expect(store).toEqual({
|
||||
selectedTreeNodeIds: ["conn-1", "conn-2"],
|
||||
selectedTreeNodeId: "conn-1",
|
||||
treeSelectionAnchorId: "conn-1",
|
||||
connectionMultiSelectActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an unrelated multi-selection unchanged", () => {
|
||||
const store = target();
|
||||
applyConnectionMultiSelection(store, tick(emptyConnectionMultiSelection(), "conn-1", "conn-2"));
|
||||
|
||||
releaseConnectionFromMultiSelection(store, "conn-3");
|
||||
|
||||
expect(store.selectedTreeNodeIds).toEqual(["conn-1", "conn-2"]);
|
||||
expect(store.connectionMultiSelectActive).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Connection multi-selection state for the sidebar tree.
|
||||
*
|
||||
* The transitions live here rather than in the components because the checkbox
|
||||
* that grows the selection and the bulk actions that release it sit in two
|
||||
* different files, and only their combination decides what the next bulk action
|
||||
* operates on.
|
||||
*/
|
||||
export type ConnectionMultiSelection = {
|
||||
connectionIds: string[];
|
||||
activeConnectionId: string | null;
|
||||
anchorConnectionId: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
/** The tree selection fields a multi-selection is written back to. */
|
||||
export type ConnectionMultiSelectionTarget = {
|
||||
selectedTreeNodeIds: string[];
|
||||
selectedTreeNodeId: string | null;
|
||||
treeSelectionAnchorId: string | null;
|
||||
connectionMultiSelectActive: boolean;
|
||||
};
|
||||
|
||||
export function emptyConnectionMultiSelection(): ConnectionMultiSelection {
|
||||
return { connectionIds: [], activeConnectionId: null, anchorConnectionId: null, active: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticking a checkbox adds to the current selection while multi-select is on, and
|
||||
* starts a new one when it is off. A bulk action that forgets to release the
|
||||
* selection therefore hands its connections to the next one (issue #5758).
|
||||
*/
|
||||
export function connectionMultiSelectionAfterToggle(selection: Pick<ConnectionMultiSelection, "connectionIds" | "active">, connectionId: string): ConnectionMultiSelection {
|
||||
const next = new Set(selection.active ? selection.connectionIds : []);
|
||||
if (next.has(connectionId)) next.delete(connectionId);
|
||||
else next.add(connectionId);
|
||||
|
||||
const connectionIds = [...next];
|
||||
return {
|
||||
connectionIds,
|
||||
activeConnectionId: next.has(connectionId) ? connectionId : (connectionIds[0] ?? null),
|
||||
anchorConnectionId: connectionId,
|
||||
active: connectionIds.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyConnectionMultiSelection(target: ConnectionMultiSelectionTarget, selection: ConnectionMultiSelection): void {
|
||||
target.selectedTreeNodeIds = selection.connectionIds;
|
||||
target.selectedTreeNodeId = selection.activeConnectionId;
|
||||
target.treeSelectionAnchorId = selection.anchorConnectionId;
|
||||
target.connectionMultiSelectActive = selection.active;
|
||||
}
|
||||
|
||||
export function releaseConnectionFromMultiSelection(target: ConnectionMultiSelectionTarget, connectionId: string): void {
|
||||
if (!target.connectionMultiSelectActive || !target.selectedTreeNodeIds.includes(connectionId)) return;
|
||||
|
||||
const connectionIds = target.selectedTreeNodeIds.filter((id) => id !== connectionId);
|
||||
const activeConnectionId = target.selectedTreeNodeId && connectionIds.includes(target.selectedTreeNodeId) ? target.selectedTreeNodeId : (connectionIds[0] ?? null);
|
||||
const anchorConnectionId = target.treeSelectionAnchorId && connectionIds.includes(target.treeSelectionAnchorId) ? target.treeSelectionAnchorId : activeConnectionId;
|
||||
applyConnectionMultiSelection(target, {
|
||||
connectionIds,
|
||||
activeConnectionId,
|
||||
anchorConnectionId,
|
||||
active: connectionIds.length > 0,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user