From e78a16211ac0dbfbba41d6c254e0a97a540c9541 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 17 Jun 2026 22:11:42 +0700 Subject: [PATCH] fix(welcome): rebuild connection tree when a new group is created (#1704) --- CHANGELOG.md | 1 + TablePro/ViewModels/WelcomeViewModel.swift | 16 ++ .../Views/Connection/WelcomeWindowView.swift | 12 +- .../ViewModels/WelcomeViewModelTests.swift | 140 ++++++++++++++++++ 4 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 TableProTests/ViewModels/WelcomeViewModelTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe3650bc..fc26afaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Redis key browsing now lists every key in a database or namespace and pages through them correctly. It was reading only the first SCAN batch, so large keyspaces showed a partial, fixed set of keys. (#1701) - A dropped Redis connection now reconnects on the next command and replays auth and the selected database, instead of failing until the next health check. (#1701) - DuckDB VARIANT columns now show their value as text instead of an empty cell. +- A new database group now appears in the connection list right away instead of only after restarting the app. (#1704) ## [0.51.1] - 2026-06-16 diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index f01d0f720..b95cf9610 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -454,6 +454,22 @@ final class WelcomeViewModel { rebuildTree() } + func createGroup(name: String, color: ConnectionColor, parentId: UUID?) { + let group = ConnectionGroup(name: name, color: color, parentId: parentId) + groupStorage.addGroup(group) + groups = groupStorage.loadGroups() + guard groups.contains(where: { $0.id == group.id }) else { return } + expandedGroupIds.insert(group.id) + if let parentId { + expandedGroupIds.insert(parentId) + } + if !pendingMoveToNewGroup.isEmpty { + moveConnections(pendingMoveToNewGroup, toGroup: group.id) + pendingMoveToNewGroup = [] + } + rebuildTree() + } + func createSubgroup(under parentId: UUID) { activeSheet = .newGroup(parentId: parentId) } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 096a9c32e..1ad9c439f 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -92,17 +92,7 @@ struct WelcomeWindowView: View { switch sheet { case .newGroup(let parentId): CreateGroupSheet(parentId: parentId) { name, color, pid in - let group = ConnectionGroup(name: name, color: color, parentId: pid) - GroupStorage.shared.addGroup(group) - vm.groups = GroupStorage.shared.loadGroups() - vm.expandedGroupIds.insert(group.id) - if let pid { - vm.expandedGroupIds.insert(pid) - } - if !vm.pendingMoveToNewGroup.isEmpty { - vm.moveConnections(vm.pendingMoveToNewGroup, toGroup: group.id) - vm.pendingMoveToNewGroup = [] - } + vm.createGroup(name: name, color: color, parentId: pid) } case .activation: LicenseActivationSheet() diff --git a/TableProTests/ViewModels/WelcomeViewModelTests.swift b/TableProTests/ViewModels/WelcomeViewModelTests.swift new file mode 100644 index 000000000..6f7909013 --- /dev/null +++ b/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -0,0 +1,140 @@ +// +// WelcomeViewModelTests.swift +// TableProTests +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +@MainActor +final class WelcomeViewModelTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + private var syncSuiteName: String! + private var syncDefaults: UserDefaults! + private var connectionFileURL: URL! + private var groupStorage: GroupStorage! + private var connectionStorage: ConnectionStorage! + private var viewModel: WelcomeViewModel! + + override func setUp() { + super.setUp() + let unique = UUID().uuidString + suiteName = "com.TablePro.tests.WelcomeViewModel.\(unique)" + syncSuiteName = "com.TablePro.tests.WelcomeViewModel.sync.\(unique)" + guard let defaults = UserDefaults(suiteName: suiteName), + let syncDefaults = UserDefaults(suiteName: syncSuiteName) else { + XCTFail("Could not create isolated UserDefaults suites") + return + } + self.defaults = defaults + self.syncDefaults = syncDefaults + let tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: syncDefaults)) + connectionFileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("welcome-connections_\(unique).json") + try? FileManager.default.createDirectory( + at: connectionFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + connectionStorage = ConnectionStorage( + fileURL: connectionFileURL, + userDefaults: defaults, + syncTracker: tracker + ) + groupStorage = GroupStorage( + userDefaults: defaults, + syncTracker: tracker, + connectionStorage: self.connectionStorage + ) + viewModel = WelcomeViewModel(services: makeServices()) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + syncDefaults.removePersistentDomain(forName: syncSuiteName) + try? FileManager.default.removeItem(at: connectionFileURL) + viewModel = nil + groupStorage = nil + connectionStorage = nil + defaults = nil + syncDefaults = nil + suiteName = nil + syncSuiteName = nil + connectionFileURL = nil + super.tearDown() + } + + private func makeServices() -> AppServices { + let live = AppServices.live + return AppServices( + appEvents: live.appEvents, + appSettings: live.appSettings, + appSettingsStorage: live.appSettingsStorage, + connectionStorage: connectionStorage, + databaseManager: live.databaseManager, + pluginManager: live.pluginManager, + schemaService: live.schemaService, + schemaProviderRegistry: live.schemaProviderRegistry, + sqlFavoriteManager: live.sqlFavoriteManager, + favoriteTablesStorage: live.favoriteTablesStorage, + aiChatStorage: live.aiChatStorage, + aiKeyStorage: live.aiKeyStorage, + groupStorage: groupStorage, + tagStorage: live.tagStorage, + sshProfileStorage: live.sshProfileStorage, + licenseManager: live.licenseManager, + conflictResolver: live.conflictResolver, + syncMetadataStorage: live.syncMetadataStorage, + favoritesExpansionState: live.favoritesExpansionState, + linkedFolderWatcher: live.linkedFolderWatcher, + queryHistoryManager: live.queryHistoryManager, + dateFormattingService: live.dateFormattingService, + copilotService: live.copilotService, + mcpServerManager: live.mcpServerManager, + syncTracker: live.syncTracker, + themeEngine: live.themeEngine + ) + } + + private func groupIds(in nodes: [ConnectionGroupTreeNode]) -> [UUID] { + nodes.flatMap { node -> [UUID] in + guard case .group(let group, let children) = node else { return [] } + return [group.id] + groupIds(in: children) + } + } + + func testCreateGroupShowsImmediatelyInTree() throws { + XCTAssertTrue(groupIds(in: viewModel.treeItems).isEmpty) + + viewModel.createGroup(name: "Production", color: .red, parentId: nil) + + let created = try XCTUnwrap(groupStorage.loadGroups().first { $0.name == "Production" }) + XCTAssertTrue(groupIds(in: viewModel.treeItems).contains(created.id)) + XCTAssertTrue(viewModel.expandedGroupIds.contains(created.id)) + } + + func testCreateSubgroupExpandsParentAndChild() throws { + viewModel.createGroup(name: "Parent", color: .none, parentId: nil) + let parentId = try XCTUnwrap(groupStorage.loadGroups().first { $0.name == "Parent" }?.id) + + viewModel.createGroup(name: "Child", color: .none, parentId: parentId) + let childId = try XCTUnwrap(groupStorage.loadGroups().first { $0.name == "Child" }?.id) + + XCTAssertTrue(groupIds(in: viewModel.treeItems).contains(parentId)) + XCTAssertTrue(groupIds(in: viewModel.treeItems).contains(childId)) + XCTAssertTrue(viewModel.expandedGroupIds.contains(parentId)) + XCTAssertTrue(viewModel.expandedGroupIds.contains(childId)) + } + + func testCreateDuplicateNameDoesNotAddSecondNode() { + viewModel.createGroup(name: "Staging", color: .orange, parentId: nil) + viewModel.createGroup(name: "staging", color: .blue, parentId: nil) + + let stagingNodes = groupIds(in: viewModel.treeItems).filter { id in + viewModel.groups.first { $0.id == id }?.name.lowercased() == "staging" + } + XCTAssertEqual(stagingNodes.count, 1) + } +}