fix(sidebar): clear selection after connection moves

This commit is contained in:
William Yang authored and GitHub committed 2026-08-11 21:37:31 +08:00
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 });
}
@@ -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,
});
}