fix(sidebar): refresh object counts after drop
This commit is contained in:
1 parent
959af4d841
commit
2ec9328eea
13 files changed
+269
-10
No files matched your search
@@ -2030,15 +2030,17 @@ async function confirmDropObject() {
|
|||||||
const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
|
const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
|
||||||
toast(t(msgKey, { name: node.label }), 3000);
|
toast(t(msgKey, { name: node.label }), 3000);
|
||||||
closeDroppedTableObjectTabsForNode(node);
|
closeDroppedTableObjectTabsForNode(node);
|
||||||
// Procedure/function drops refresh their parent instead of removing this
|
// Refresh the parent object list so group badges and children stay in sync.
|
||||||
// node directly, so clear their pin before the old identity can survive.
|
// Clear the pin first — refresh rebuilds nodes and the old identity must not survive.
|
||||||
connectionStore.removePinnedTreeNodes([node]);
|
connectionStore.removePinnedTreeNodes([node]);
|
||||||
if (node.type === "view" || node.type === "materialized_view") {
|
try {
|
||||||
connectionStore.removeTreeNode(node.id);
|
|
||||||
releaseActiveNodeReference([node.id]);
|
|
||||||
} else {
|
|
||||||
await refreshTableList(node);
|
await refreshTableList(node);
|
||||||
|
} catch (error: any) {
|
||||||
|
// DROP already succeeded; keep the sidebar consistent if metadata refresh fails.
|
||||||
|
connectionStore.removeTreeNode(node.id);
|
||||||
|
toast(t("contextMenu.objectDropRefreshFailed", { message: error?.message || String(error) }), 5000);
|
||||||
}
|
}
|
||||||
|
releaseActiveNodeReference([node.id]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||||
}
|
}
|
||||||
@@ -2134,6 +2136,7 @@ async function confirmBatchDrop() {
|
|||||||
showBatchDropConfirm.value = false;
|
showBatchDropConfirm.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const refreshScopes = new Map<string, TreeNode>();
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
if (!target.connectionId || !target.database) continue;
|
if (!target.connectionId || !target.database) continue;
|
||||||
await connectionStore.ensureConnected(target.connectionId);
|
await connectionStore.ensureConnected(target.connectionId);
|
||||||
@@ -2141,11 +2144,18 @@ async function confirmBatchDrop() {
|
|||||||
if (!sql) continue;
|
if (!sql) continue;
|
||||||
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
|
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
|
||||||
closeDroppedTableObjectTabsForNode(target);
|
closeDroppedTableObjectTabsForNode(target);
|
||||||
|
// Remove immediately so a later failure cannot leave dropped objects in the tree.
|
||||||
connectionStore.removeTreeNode(target.id);
|
connectionStore.removeTreeNode(target.id);
|
||||||
releaseActiveNodeReference([target.id]);
|
releaseActiveNodeReference([target.id]);
|
||||||
|
refreshScopes.set(`${target.connectionId}:${target.database}:${target.schema ?? ""}`, target);
|
||||||
}
|
}
|
||||||
toast(t("contextMenu.batchDropSuccess", { count: targets.length }), 3000);
|
toast(t("contextMenu.batchDropSuccess", { count: targets.length }), 3000);
|
||||||
showBatchDropConfirm.value = false;
|
showBatchDropConfirm.value = false;
|
||||||
|
const refreshResults = await Promise.allSettled([...refreshScopes.values()].map((target) => refreshTableList(target)));
|
||||||
|
const refreshFailure = refreshResults.find((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||||
|
if (refreshFailure) {
|
||||||
|
toast(t("contextMenu.objectDropRefreshFailed", { message: refreshFailure.reason?.message || String(refreshFailure.reason) }), 5000);
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2166,6 +2166,7 @@ export default {
|
|||||||
truncateTableSuccess: 'Table "{name}" truncated',
|
truncateTableSuccess: 'Table "{name}" truncated',
|
||||||
duplicateStructureSuccess: 'Table cloned as "{name}"',
|
duplicateStructureSuccess: 'Table cloned as "{name}"',
|
||||||
tableOperationFailed: "Operation failed: {message}",
|
tableOperationFailed: "Operation failed: {message}",
|
||||||
|
objectDropRefreshFailed: "Objects were deleted, but refreshing the sidebar failed: {message}",
|
||||||
duplicateNameTitle: "Clone as New Table",
|
duplicateNameTitle: "Clone as New Table",
|
||||||
duplicateNamePlaceholder: "New table name",
|
duplicateNamePlaceholder: "New table name",
|
||||||
copyTable: "Copy Table",
|
copyTable: "Copy Table",
|
||||||
|
|||||||
@@ -2101,6 +2101,7 @@ export default withEnglishFallback({
|
|||||||
truncateTableSuccess: 'Tabla "{name}" truncada',
|
truncateTableSuccess: 'Tabla "{name}" truncada',
|
||||||
duplicateStructureSuccess: 'Tabla clonada como "{name}"',
|
duplicateStructureSuccess: 'Tabla clonada como "{name}"',
|
||||||
tableOperationFailed: "Error en la operación: {message}",
|
tableOperationFailed: "Error en la operación: {message}",
|
||||||
|
objectDropRefreshFailed: "Los objetos se eliminaron, pero no se pudo actualizar la barra lateral: {message}",
|
||||||
duplicateNameTitle: "Clonar como tabla nueva",
|
duplicateNameTitle: "Clonar como tabla nueva",
|
||||||
duplicateNamePlaceholder: "Nombre de la nueva tabla",
|
duplicateNamePlaceholder: "Nombre de la nueva tabla",
|
||||||
copyTable: "Copiar tabla",
|
copyTable: "Copiar tabla",
|
||||||
|
|||||||
@@ -2099,6 +2099,7 @@ export default withEnglishFallback({
|
|||||||
truncateTableSuccess: 'Tabella "{name}" troncata',
|
truncateTableSuccess: 'Tabella "{name}" troncata',
|
||||||
duplicateStructureSuccess: 'Tabella clonata come "{name}"',
|
duplicateStructureSuccess: 'Tabella clonata come "{name}"',
|
||||||
tableOperationFailed: "Operazione non riuscita: {message}",
|
tableOperationFailed: "Operazione non riuscita: {message}",
|
||||||
|
objectDropRefreshFailed: "Gli oggetti sono stati eliminati, ma l'aggiornamento della barra laterale non è riuscito: {message}",
|
||||||
duplicateNameTitle: "Clona as Nuova Tabella",
|
duplicateNameTitle: "Clona as Nuova Tabella",
|
||||||
duplicateNamePlaceholder: "Nuovo nome tabella",
|
duplicateNamePlaceholder: "Nuovo nome tabella",
|
||||||
copyTable: "Copia tabella",
|
copyTable: "Copia tabella",
|
||||||
|
|||||||
@@ -2096,6 +2096,7 @@ export default withEnglishFallback({
|
|||||||
truncateTableSuccess: "テーブル「{name}」をトランケートしました",
|
truncateTableSuccess: "テーブル「{name}」をトランケートしました",
|
||||||
duplicateStructureSuccess: "テーブルを「{name}」として複製しました",
|
duplicateStructureSuccess: "テーブルを「{name}」として複製しました",
|
||||||
tableOperationFailed: "操作に失敗しました: {message}",
|
tableOperationFailed: "操作に失敗しました: {message}",
|
||||||
|
objectDropRefreshFailed: "オブジェクトは削除されましたが、サイドバーの更新に失敗しました: {message}",
|
||||||
duplicateNameTitle: "新しいテーブルとして複製",
|
duplicateNameTitle: "新しいテーブルとして複製",
|
||||||
duplicateNamePlaceholder: "新しいテーブル名",
|
duplicateNamePlaceholder: "新しいテーブル名",
|
||||||
copyTable: "テーブルをコピー",
|
copyTable: "テーブルをコピー",
|
||||||
|
|||||||
@@ -2137,6 +2137,7 @@ export default withEnglishFallback({
|
|||||||
truncateTableSuccess: '테이블 "{name}" TRUNCATE됨',
|
truncateTableSuccess: '테이블 "{name}" TRUNCATE됨',
|
||||||
duplicateStructureSuccess: '테이블이 "{name}"(으)로 복제됨',
|
duplicateStructureSuccess: '테이블이 "{name}"(으)로 복제됨',
|
||||||
tableOperationFailed: "작업 실패: {message}",
|
tableOperationFailed: "작업 실패: {message}",
|
||||||
|
objectDropRefreshFailed: "개체는 삭제되었지만 사이드바 새로 고침에 실패했습니다: {message}",
|
||||||
duplicateNameTitle: "새 테이블로 복제",
|
duplicateNameTitle: "새 테이블로 복제",
|
||||||
duplicateNamePlaceholder: "새 테이블 이름",
|
duplicateNamePlaceholder: "새 테이블 이름",
|
||||||
copyTable: "테이블 복사",
|
copyTable: "테이블 복사",
|
||||||
|
|||||||
@@ -2101,6 +2101,7 @@ export default withEnglishFallback({
|
|||||||
truncateTableSuccess: 'Tabela "{name}" truncada',
|
truncateTableSuccess: 'Tabela "{name}" truncada',
|
||||||
duplicateStructureSuccess: 'Tabela clonada como "{name}"',
|
duplicateStructureSuccess: 'Tabela clonada como "{name}"',
|
||||||
tableOperationFailed: "Falha na operação: {message}",
|
tableOperationFailed: "Falha na operação: {message}",
|
||||||
|
objectDropRefreshFailed: "Os objetos foram excluídos, mas não foi possível atualizar a barra lateral: {message}",
|
||||||
duplicateNameTitle: "Clonar como Nova Tabela",
|
duplicateNameTitle: "Clonar como Nova Tabela",
|
||||||
duplicateNamePlaceholder: "Nome da nova tabela",
|
duplicateNamePlaceholder: "Nome da nova tabela",
|
||||||
copyTable: "Copiar tabela",
|
copyTable: "Copiar tabela",
|
||||||
|
|||||||
@@ -2167,6 +2167,7 @@ export default withEnglishFallback({
|
|||||||
truncateTableSuccess: "表「{name}」已截断",
|
truncateTableSuccess: "表「{name}」已截断",
|
||||||
duplicateStructureSuccess: "已克隆为新表「{name}」",
|
duplicateStructureSuccess: "已克隆为新表「{name}」",
|
||||||
tableOperationFailed: "操作失败:{message}",
|
tableOperationFailed: "操作失败:{message}",
|
||||||
|
objectDropRefreshFailed: "对象已删除,但侧边栏刷新失败:{message}",
|
||||||
duplicateNameTitle: "克隆为新表",
|
duplicateNameTitle: "克隆为新表",
|
||||||
duplicateNamePlaceholder: "新表名",
|
duplicateNamePlaceholder: "新表名",
|
||||||
copyTable: "复制表",
|
copyTable: "复制表",
|
||||||
|
|||||||
@@ -2100,6 +2100,7 @@ export default withEnglishFallback({
|
|||||||
truncateTableSuccess: "資料表「{name}」已截斷",
|
truncateTableSuccess: "資料表「{name}」已截斷",
|
||||||
duplicateStructureSuccess: "已克隆為新資料表「{name}」",
|
duplicateStructureSuccess: "已克隆為新資料表「{name}」",
|
||||||
tableOperationFailed: "操作失敗:{message}",
|
tableOperationFailed: "操作失敗:{message}",
|
||||||
|
objectDropRefreshFailed: "物件已刪除,但側邊欄重新整理失敗:{message}",
|
||||||
duplicateNameTitle: "克隆為新資料表",
|
duplicateNameTitle: "克隆為新資料表",
|
||||||
duplicateNamePlaceholder: "新資料表名稱",
|
duplicateNamePlaceholder: "新資料表名稱",
|
||||||
copyTable: "複製資料表",
|
copyTable: "複製資料表",
|
||||||
|
|||||||
@@ -58,6 +58,60 @@ describe("connectionStore pinned tree node removal", () => {
|
|||||||
expect(store.isTreeNodePinned(replacement)).toBe(false);
|
expect(store.isTreeNodePinned(replacement)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recounts parent objectCount from remaining children when a child is removed", async () => {
|
||||||
|
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||||
|
|
||||||
|
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||||
|
const store = useConnectionStore();
|
||||||
|
const view1 = {
|
||||||
|
id: "conn:db:public:v1",
|
||||||
|
label: "v1",
|
||||||
|
type: "view" as const,
|
||||||
|
connectionId: "conn",
|
||||||
|
database: "db",
|
||||||
|
schema: "public",
|
||||||
|
};
|
||||||
|
const view2 = {
|
||||||
|
id: "conn:db:public:v2",
|
||||||
|
label: "v2",
|
||||||
|
type: "view" as const,
|
||||||
|
connectionId: "conn",
|
||||||
|
database: "db",
|
||||||
|
schema: "public",
|
||||||
|
};
|
||||||
|
const loadMore = {
|
||||||
|
id: "conn:db:public:__views:__load_more",
|
||||||
|
label: "Load more",
|
||||||
|
type: "load-more" as const,
|
||||||
|
connectionId: "conn",
|
||||||
|
database: "db",
|
||||||
|
};
|
||||||
|
const viewsGroup: TreeNode = {
|
||||||
|
id: "conn:db:public:__views",
|
||||||
|
label: "Views",
|
||||||
|
type: "group-views",
|
||||||
|
connectionId: "conn",
|
||||||
|
database: "db",
|
||||||
|
schema: "public",
|
||||||
|
objectCount: 99,
|
||||||
|
children: [view1, view2, loadMore],
|
||||||
|
};
|
||||||
|
store.treeNodes = [
|
||||||
|
{
|
||||||
|
id: "conn",
|
||||||
|
label: "Connection",
|
||||||
|
type: "connection",
|
||||||
|
connectionId: "conn",
|
||||||
|
children: [viewsGroup],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
store.removeTreeNode(view1.id);
|
||||||
|
|
||||||
|
expect(viewsGroup.children?.map((child) => child.id)).toEqual([view2.id, loadMore.id]);
|
||||||
|
expect(viewsGroup.objectCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("serializes desktop pin saves so an older reorder cannot overwrite the latest one", async () => {
|
it("serializes desktop pin saves so an older reorder cannot overwrite the latest one", async () => {
|
||||||
const savePinnedTreeNodeIds = vi.fn().mockResolvedValue(undefined);
|
const savePinnedTreeNodeIds = vi.fn().mockResolvedValue(undefined);
|
||||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConnectionConfig, TreeNode } from "@/types/database";
|
||||||
|
|
||||||
|
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)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function postgresConnection(): ConnectionConfig {
|
||||||
|
return {
|
||||||
|
id: "pg-1",
|
||||||
|
name: "Postgres",
|
||||||
|
db_type: "postgres",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 5432,
|
||||||
|
username: "postgres",
|
||||||
|
password: "",
|
||||||
|
database: "app",
|
||||||
|
} as ConnectionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaNode(children: TreeNode[]): TreeNode {
|
||||||
|
return {
|
||||||
|
id: "pg-1:app:public",
|
||||||
|
label: "public",
|
||||||
|
type: "schema",
|
||||||
|
connectionId: "pg-1",
|
||||||
|
database: "app",
|
||||||
|
schema: "public",
|
||||||
|
isExpanded: true,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectGroup(type: "group-views" | "group-procedures", key: string, child: TreeNode): TreeNode {
|
||||||
|
return {
|
||||||
|
id: `pg-1:app:public:${key}`,
|
||||||
|
label: type === "group-views" ? "tree.views" : "tree.procedures",
|
||||||
|
type,
|
||||||
|
connectionId: "pg-1",
|
||||||
|
database: "app",
|
||||||
|
schema: "public",
|
||||||
|
isExpanded: true,
|
||||||
|
objectCount: 7,
|
||||||
|
children: [child],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function installApiMocks(options?: { listTables?: ReturnType<typeof vi.fn>; listObjects?: ReturnType<typeof vi.fn> }) {
|
||||||
|
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||||
|
vi.doMock("@/lib/backend/api", () => ({
|
||||||
|
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||||
|
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||||
|
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||||
|
listObjects: options?.listObjects ?? vi.fn().mockResolvedValue([]),
|
||||||
|
listTables: options?.listTables ?? vi.fn().mockResolvedValue([]),
|
||||||
|
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||||
|
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||||
|
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||||
|
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStore(root: TreeNode) {
|
||||||
|
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||||
|
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||||
|
const store = useConnectionStore();
|
||||||
|
useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped";
|
||||||
|
const connection = postgresConnection();
|
||||||
|
store.connections = [connection];
|
||||||
|
store.connectedIds.add(connection.id);
|
||||||
|
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, isExpanded: true, children: [root] }];
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("connectionStore tree refresh state", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
installLocalStorage();
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an object group count while rebuilding grouped placeholders", async () => {
|
||||||
|
installApiMocks();
|
||||||
|
const oldView: TreeNode = { id: "pg-1:app:public:old_view", label: "old_view", type: "view", connectionId: "pg-1", database: "app", schema: "public" };
|
||||||
|
const root = schemaNode([objectGroup("group-views", "__views", oldView)]);
|
||||||
|
root.children![0]!.isExpanded = false;
|
||||||
|
const store = await createStore(root);
|
||||||
|
|
||||||
|
await store.loadTreeNodeChildren(root, { force: true });
|
||||||
|
|
||||||
|
expect(root.children?.find((child) => child.type === "group-views")?.objectCount).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back tree data and loaded markers when an expanded child refresh fails", async () => {
|
||||||
|
const listTables = vi.fn().mockResolvedValue([{ name: "fresh_view", table_type: "VIEW", comment: null }]);
|
||||||
|
const listObjects = vi.fn().mockRejectedValue(new Error("metadata denied"));
|
||||||
|
installApiMocks({ listTables, listObjects });
|
||||||
|
|
||||||
|
const oldView: TreeNode = { id: "pg-1:app:public:old_view", label: "old_view", type: "view", connectionId: "pg-1", database: "app", schema: "public" };
|
||||||
|
const oldProcedure: TreeNode = { id: "pg-1:app:public:old_procedure", label: "old_procedure", type: "procedure", connectionId: "pg-1", database: "app", schema: "public" };
|
||||||
|
const root = schemaNode([objectGroup("group-views", "__views", oldView), objectGroup("group-procedures", "__procedures", oldProcedure)]);
|
||||||
|
const hiddenChildren: TreeNode[] = [{ id: "pg-1:app:public:hidden", label: "hidden", type: "view", connectionId: "pg-1", database: "app", schema: "public" }];
|
||||||
|
root.hiddenChildren = hiddenChildren;
|
||||||
|
root.objectCount = 42;
|
||||||
|
const store = await createStore(root);
|
||||||
|
|
||||||
|
await store.loadTreeNodeChildren(root, { force: true });
|
||||||
|
const previousChildren = root.children;
|
||||||
|
const viewsGroupId = root.children!.find((child) => child.type === "group-views")!.id;
|
||||||
|
const proceduresGroupId = root.children!.find((child) => child.type === "group-procedures")!.id;
|
||||||
|
expect(store.isTreeNodeChildrenLoaded(root.id)).toBe(true);
|
||||||
|
expect(store.isTreeNodeChildrenLoaded(viewsGroupId)).toBe(false);
|
||||||
|
|
||||||
|
await expect(store.refreshTreeNode(root)).rejects.toThrow("metadata denied");
|
||||||
|
|
||||||
|
expect(listTables).toHaveBeenCalledOnce();
|
||||||
|
expect(listObjects).toHaveBeenCalledOnce();
|
||||||
|
expect(root.children).toBe(previousChildren);
|
||||||
|
expect(root.hiddenChildren).toBe(hiddenChildren);
|
||||||
|
expect(root.objectCount).toBe(42);
|
||||||
|
expect(root.children?.find((child) => child.type === "group-views")?.children?.map((child) => child.label)).toEqual(["old_view"]);
|
||||||
|
expect(root.children?.find((child) => child.type === "group-procedures")?.children?.map((child) => child.label)).toEqual(["old_procedure"]);
|
||||||
|
expect(store.isTreeNodeChildrenLoaded(root.id)).toBe(true);
|
||||||
|
expect(store.isTreeNodeChildrenLoaded(viewsGroupId)).toBe(false);
|
||||||
|
expect(store.isTreeNodeChildrenLoaded(proceduresGroupId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1244,14 +1244,19 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
const isExpanded = old.isExpanded;
|
const isExpanded = old.isExpanded;
|
||||||
const isLoading = old.isLoading;
|
const isLoading = old.isLoading;
|
||||||
const oldChildren = old.children;
|
const oldChildren = old.children;
|
||||||
|
const objectCount = child.objectCount ?? old.objectCount;
|
||||||
Object.assign(old, child);
|
Object.assign(old, child);
|
||||||
old.isExpanded = isExpanded;
|
old.isExpanded = isExpanded;
|
||||||
old.isLoading = isLoading;
|
old.isLoading = isLoading;
|
||||||
old.children = oldChildren;
|
old.children = oldChildren;
|
||||||
|
old.objectCount = objectCount;
|
||||||
return old;
|
return old;
|
||||||
}
|
}
|
||||||
if (old?.isExpanded) {
|
if (old?.isExpanded) {
|
||||||
return { ...child, isExpanded: true, children: old.children };
|
return { ...child, isExpanded: true, children: old.children, objectCount: child.objectCount ?? old.objectCount };
|
||||||
|
}
|
||||||
|
if (old && objectTypesForGroupNode(old.type)) {
|
||||||
|
return { ...child, objectCount: child.objectCount ?? old.objectCount };
|
||||||
}
|
}
|
||||||
// Same-id collapsed database/schema shell replace (e.g. DDL → force loadDatabases):
|
// Same-id collapsed database/schema shell replace (e.g. DDL → force loadDatabases):
|
||||||
// prior confirmed-empty markers belong to the discarded instance and must not skip
|
// prior confirmed-empty markers belong to the discarded instance and must not skip
|
||||||
@@ -1304,6 +1309,13 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
const parent = findParentNode(treeNodes.value, nodeId);
|
const parent = findParentNode(treeNodes.value, nodeId);
|
||||||
if (parent?.children) {
|
if (parent?.children) {
|
||||||
parent.children = parent.children.filter((c) => c.id !== nodeId);
|
parent.children = parent.children.filter((c) => c.id !== nodeId);
|
||||||
|
// Keep the group badge in sync with remaining real children (exclude load-more).
|
||||||
|
if (parent.objectCount != null) {
|
||||||
|
parent.objectCount = withoutLoadMoreNodes(parent.children).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parent?.hiddenChildren) {
|
||||||
|
parent.hiddenChildren = parent.hiddenChildren.filter((child) => child.id !== nodeId);
|
||||||
}
|
}
|
||||||
if (selectedTreeNodeId.value === nodeId) selectedTreeNodeId.value = null;
|
if (selectedTreeNodeId.value === nodeId) selectedTreeNodeId.value = null;
|
||||||
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => id !== nodeId);
|
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => id !== nodeId);
|
||||||
@@ -5116,13 +5128,28 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||||||
if (node.connectionId && !connectedIds.value.has(node.connectionId)) return;
|
if (node.connectionId && !connectedIds.value.has(node.connectionId)) return;
|
||||||
const expandedIds = collectExpandedNodeIds([node]);
|
const expandedIds = collectExpandedNodeIds([node]);
|
||||||
expandedIds.add(node.id);
|
expandedIds.add(node.id);
|
||||||
|
const previousChildren = node.children;
|
||||||
|
const previousHiddenChildren = node.hiddenChildren;
|
||||||
|
const previousObjectCount = node.objectCount;
|
||||||
|
const previousLoadedIds = [...loadedTreeNodeChildrenIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`));
|
||||||
|
const previousConfirmedEmptyIds = [...confirmedEmptyTreeNodeIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`));
|
||||||
await clearPersistedTreeCacheForNode(node);
|
await clearPersistedTreeCacheForNode(node);
|
||||||
clearLoadedChildrenCache(node.id);
|
clearLoadedChildrenCache(node.id);
|
||||||
if (node.type !== "connection-group") {
|
if (node.type !== "connection-group") {
|
||||||
node.children = [];
|
node.children = [];
|
||||||
}
|
}
|
||||||
await loadTreeNodeChildren(node, { force: true });
|
try {
|
||||||
await restoreExpandedChildren(node, expandedIds, { force: true });
|
await loadTreeNodeChildren(node, { force: true });
|
||||||
|
await restoreExpandedChildren(node, expandedIds, { force: true });
|
||||||
|
} catch (error) {
|
||||||
|
node.children = previousChildren;
|
||||||
|
node.hiddenChildren = previousHiddenChildren;
|
||||||
|
node.objectCount = previousObjectCount;
|
||||||
|
clearLoadedChildrenCache(node.id, { deletePersisted: false });
|
||||||
|
for (const id of previousLoadedIds) loadedTreeNodeChildrenIds.value.add(id);
|
||||||
|
for (const id of previousConfirmedEmptyIds) confirmedEmptyTreeNodeIds.value.add(id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) {
|
async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) {
|
||||||
|
|||||||
+27
-1
@@ -6,6 +6,7 @@ const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTre
|
|||||||
const connectionMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts", "utf8");
|
const connectionMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarConnectionMutationRuntime.ts", "utf8");
|
||||||
const databaseSpecificMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts", "utf8");
|
const databaseSpecificMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarDatabaseSpecificMutationRuntime.ts", "utf8");
|
||||||
const tableMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarTableMutationRuntime.ts", "utf8");
|
const tableMutationRuntime = readFileSync("apps/desktop/src/composables/useSidebarTableMutationRuntime.ts", "utf8");
|
||||||
|
const localeSources = Object.fromEntries(["en", "es", "it", "ja", "ko", "pt-BR", "zh-CN", "zh-TW"].map((locale) => [locale, readFileSync(`apps/desktop/src/i18n/locales/${locale}.ts`, "utf8")]));
|
||||||
|
|
||||||
function functionBody(name: string): string {
|
function functionBody(name: string): string {
|
||||||
const source = [runtimeHost, connectionMutationRuntime, databaseSpecificMutationRuntime, tableMutationRuntime].find((candidate) => candidate.includes(`function ${name}(`));
|
const source = [runtimeHost, connectionMutationRuntime, databaseSpecificMutationRuntime, tableMutationRuntime].find((candidate) => candidate.includes(`function ${name}(`));
|
||||||
@@ -48,7 +49,6 @@ test("mutation families retain accepted targets, failures, and refresh work", ()
|
|||||||
assert.match(dropMongoCollection, /onError:\s*toastMutationError/);
|
assert.match(dropMongoCollection, /onError:\s*toastMutationError/);
|
||||||
assert.match(dropMongoCollection, /api\.mongoDropCollection/);
|
assert.match(dropMongoCollection, /api\.mongoDropCollection/);
|
||||||
|
|
||||||
|
|
||||||
const redis = functionBody("confirmFlushRedisDb");
|
const redis = functionBody("confirmFlushRedisDb");
|
||||||
assert.match(redis, /updateRedisDbKeyStats/);
|
assert.match(redis, /updateRedisDbKeyStats/);
|
||||||
assert.match(redis, /dbx-redis-db-flushed/);
|
assert.match(redis, /dbx-redis-db-flushed/);
|
||||||
@@ -66,3 +66,29 @@ test("menu and dialog mutations resolve immutable accepted targets", () => {
|
|||||||
assert.match(runtimeHost, /routedRequest\.confirm = async \(\) => \{[\s\S]*?activateActionTarget\(target\)/);
|
assert.match(runtimeHost, /routedRequest\.confirm = async \(\) => \{[\s\S]*?activateActionTarget\(target\)/);
|
||||||
assert.match(runtimeHost, /createRoutedSidebarDialogController\(controller, \{[\s\S]*?wrapAction: \(action\) => \{[\s\S]*?activateActionTarget\(target\)/);
|
assert.match(runtimeHost, /createRoutedSidebarDialogController\(controller, \{[\s\S]*?wrapAction: \(action\) => \{[\s\S]*?activateActionTarget\(target\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("object drops distinguish completed deletes from sidebar refresh failures", () => {
|
||||||
|
const dropObject = functionBody("confirmDropObject");
|
||||||
|
assert.match(dropObject, /removePinnedTreeNodes\(\[node\]\)[\s\S]*?try \{[\s\S]*?await refreshTableList\(node\)[\s\S]*?catch \(error: any\) \{[\s\S]*?removeTreeNode\(node\.id\)[\s\S]*?objectDropRefreshFailed[\s\S]*?releaseActiveNodeReference\(\[node\.id\]\)/);
|
||||||
|
|
||||||
|
const batchDrop = functionBody("confirmBatchDrop");
|
||||||
|
const refreshBranch = batchDrop.slice(batchDrop.indexOf("const refreshScopes = new Map"));
|
||||||
|
const removeIndex = refreshBranch.indexOf("connectionStore.removeTreeNode(target.id)");
|
||||||
|
const successIndex = refreshBranch.indexOf('toast(t("contextMenu.batchDropSuccess"');
|
||||||
|
const closeIndex = refreshBranch.indexOf("showBatchDropConfirm.value = false", successIndex);
|
||||||
|
const refreshIndex = refreshBranch.indexOf("Promise.allSettled");
|
||||||
|
const warningIndex = refreshBranch.indexOf('toast(t("contextMenu.objectDropRefreshFailed"', refreshIndex);
|
||||||
|
|
||||||
|
assert.ok(removeIndex >= 0 && successIndex > removeIndex, "successful deletes must be reflected before the success toast");
|
||||||
|
assert.ok(closeIndex > successIndex && refreshIndex > closeIndex, "the completed delete UI must close before metadata refreshes settle");
|
||||||
|
assert.ok(warningIndex > refreshIndex, "refresh failures must be reported separately after deletion succeeds");
|
||||||
|
assert.doesNotMatch(refreshBranch, /for \(const target of refreshScopes\.values\(\)\) \{\s*await refreshTableList/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all locales define the object drop refresh warning with its message placeholder", () => {
|
||||||
|
for (const [locale, source] of Object.entries(localeSources)) {
|
||||||
|
const keys = source.match(/\bobjectDropRefreshFailed\s*:/g) ?? [];
|
||||||
|
assert.equal(keys.length, 1, `${locale} must define objectDropRefreshFailed exactly once`);
|
||||||
|
assert.match(source, /objectDropRefreshFailed\s*:\s*["'][^\n]*\{message\}/, `${locale} must preserve the message placeholder`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user