diff --git a/CHANGELOG.md b/CHANGELOG.md index dfa04cedc..9f92e9bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connections can now have more than one tag. Assign several tags in the connection form, and filter the welcome list by tag with Match Any or Match All. (#744) - Elasticsearch support. Connect to Elasticsearch 7.x and 8.x, browse indices, run Query DSL requests in a console, and edit documents in the data grid. Install from Settings > Plugins. (#1529) +- The connection switcher and welcome list now show each connection's tags and group, so you can tell production from staging at a glance. (#1323) ### Fixed diff --git a/TablePro/Views/Connection/ConnectionMetadataBadges.swift b/TablePro/Views/Connection/ConnectionMetadataBadges.swift new file mode 100644 index 000000000..2c8530819 --- /dev/null +++ b/TablePro/Views/Connection/ConnectionMetadataBadges.swift @@ -0,0 +1,83 @@ +// +// ConnectionMetadataBadges.swift +// TablePro +// + +import SwiftUI + +enum ConnectionMetadata { + static func resolve( + connection: DatabaseConnection, + tags: [ConnectionTag], + groups: [ConnectionGroup] + ) -> (tags: [ConnectionTag], group: ConnectionGroup?) { + let resolvedTags = connection.tagIds.compactMap { id in tags.first { $0.id == id } } + let group = connection.groupId.flatMap { id in groups.first { $0.id == id } } + return (resolvedTags, group) + } +} + +struct ConnectionTagsBadgeLayout: Equatable { + let shown: [ConnectionTag] + let overflow: Int + let name: String? + + init(tags: [ConnectionTag]) { + let visible = Array(tags.prefix(3)) + shown = visible + overflow = tags.count - visible.count + name = tags.count == 1 ? tags.first?.name : nil + } +} + +struct ConnectionTagsBadge: View { + let tags: [ConnectionTag] + + var body: some View { + if !tags.isEmpty { + let layout = ConnectionTagsBadgeLayout(tags: tags) + HStack(spacing: 4) { + ForEach(layout.shown) { tag in + Circle() + .fill(tag.color.color) + .frame(width: 8, height: 8) + } + if let name = layout.name { + Text(name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } else if layout.overflow > 0 { + Text("+\(layout.overflow)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .help(tags.map(\.name).joined(separator: ", ")) + .accessibilityElement(children: .combine) + .accessibilityLabel(String(format: String(localized: "Tags: %@"), tags.map(\.name).joined(separator: ", "))) + } + } +} + +struct ConnectionGroupBadge: View { + let group: ConnectionGroup + + private var iconColor: Color { + group.color.isDefault ? .secondary : group.color.color + } + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "folder") + .imageScale(.small) + .foregroundStyle(iconColor) + Text(group.name) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(String(format: String(localized: "Group: %@"), group.name)) + } +} diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift index aa653c722..736dbcb76 100644 --- a/TablePro/Views/Connection/WelcomeConnectionRow.swift +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -13,8 +13,12 @@ struct WelcomeConnectionRow: View { @State private var isHovering = false private let pluginManager = PluginManager.shared - private var displayTags: [ConnectionTag] { - TagStorage.shared.tags(for: connection.tagIds) + private var metadata: (tags: [ConnectionTag], group: ConnectionGroup?) { + ConnectionMetadata.resolve( + connection: connection, + tags: TagStorage.shared.loadTags(), + groups: GroupStorage.shared.loadGroups() + ) } private var showsLocalOnly: Bool { @@ -35,7 +39,8 @@ struct WelcomeConnectionRow: View { } var body: some View { - HStack { + let meta = metadata + return HStack { connection.type.iconImage .renderingMode(.template) .font(.title3) @@ -47,17 +52,24 @@ struct WelcomeConnectionRow: View { .font(.body) .foregroundStyle(.primary) - Text(connection.connectionSubtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .help(connection.connectionSubtitle) + HStack(spacing: 6) { + Text(connection.connectionSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(connection.connectionSubtitle) + + if let group = meta.group { + ConnectionGroupBadge(group: group) + .layoutPriority(1) + } + } } Spacer(minLength: 8) - trailingAccessories + trailingAccessories(tags: meta.tags) } .contentShape(Rectangle()) .onHover { hovering in isHovering = hovering } @@ -68,7 +80,7 @@ struct WelcomeConnectionRow: View { } @ViewBuilder - private var trailingAccessories: some View { + private func trailingAccessories(tags: [ConnectionTag]) -> some View { HStack(spacing: 8) { if isDriverRejected { Image(systemName: "exclamationmark.triangle.fill") @@ -86,40 +98,12 @@ struct WelcomeConnectionRow: View { .accessibilityLabel(String(localized: "Local only")) } - tagAccessory + ConnectionTagsBadge(tags: tags) favoriteButton } } - @ViewBuilder - private var tagAccessory: some View { - let tags = displayTags - if !tags.isEmpty { - let shown = Array(tags.prefix(3)) - HStack(spacing: 4) { - ForEach(shown) { tag in - Circle() - .fill(tag.color.color) - .frame(width: 8, height: 8) - } - if tags.count == 1 { - Text(tags[0].name) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } else if tags.count > shown.count { - Text("+\(tags.count - shown.count)") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .help(tags.map(\.name).joined(separator: ", ")) - .accessibilityElement(children: .combine) - .accessibilityLabel(String(format: String(localized: "Tags: %@"), tags.map(\.name).joined(separator: ", "))) - } - } - private var favoriteButton: some View { let visible = connection.isFavorite || isHovering || isSelected return Button(action: onToggleFavorite) { diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index 4b7c38892..41ff1fa3b 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -202,7 +202,12 @@ struct ConnectionSwitcherPopover: View { isActive: Bool, isConnected: Bool ) -> some View { - HStack(spacing: 8) { + let metadata = ConnectionMetadata.resolve( + connection: connection, + tags: TagStorage.shared.loadTags(), + groups: GroupStorage.shared.loadGroups() + ) + return HStack(spacing: 8) { Circle() .fill(connection.displayColor) .frame(width: 8, height: 8) @@ -212,14 +217,23 @@ struct ConnectionSwitcherPopover: View { .font(.body.weight(isActive ? .semibold : .regular)) .lineLimit(1) - Text(connectionSubtitle(connection)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 6) { + Text(connectionSubtitle(connection)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if let group = metadata.group { + ConnectionGroupBadge(group: group) + .layoutPriority(1) + } + } } Spacer() + ConnectionTagsBadge(tags: metadata.tags) + if isActive { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) diff --git a/TableProTests/Models/ConnectionMetadataTests.swift b/TableProTests/Models/ConnectionMetadataTests.swift new file mode 100644 index 000000000..a9d375f7b --- /dev/null +++ b/TableProTests/Models/ConnectionMetadataTests.swift @@ -0,0 +1,110 @@ +// +// ConnectionMetadataTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("ConnectionMetadata") +struct ConnectionMetadataTests { + private func makeConnection(tagIds: [UUID] = [], groupId: UUID? = nil) -> DatabaseConnection { + var connection = DatabaseConnection(name: "Test") + connection.tagIds = tagIds + connection.groupId = groupId + return connection + } + + private let production = ConnectionTag(name: "production", color: .red) + private let staging = ConnectionTag(name: "staging", color: .orange) + private let group = ConnectionGroup(id: UUID(), name: "Backend", sortOrder: 0) + + @Test("Resolves assigned tags in order and the assigned group") + func resolvesAssigned() { + let connection = makeConnection(tagIds: [staging.id, production.id], groupId: group.id) + + let result = ConnectionMetadata.resolve( + connection: connection, + tags: [production, staging], + groups: [group] + ) + + #expect(result.tags == [staging, production]) + #expect(result.group == group) + } + + @Test("Returns no tags and no group when nothing is assigned") + func returnsEmptyWhenUnassigned() { + let connection = makeConnection() + + let result = ConnectionMetadata.resolve( + connection: connection, + tags: [production], + groups: [group] + ) + + #expect(result.tags.isEmpty) + #expect(result.group == nil) + } + + @Test("Drops tag and group references that no longer exist") + func dropsStaleReferences() { + let connection = makeConnection(tagIds: [production.id, UUID()], groupId: UUID()) + + let result = ConnectionMetadata.resolve( + connection: connection, + tags: [production], + groups: [group] + ) + + #expect(result.tags == [production]) + #expect(result.group == nil) + } +} + +@Suite("ConnectionTagsBadgeLayout") +struct ConnectionTagsBadgeLayoutTests { + private func tags(_ count: Int) -> [ConnectionTag] { + (0 ..< count).map { ConnectionTag(name: "tag\($0)") } + } + + @Test("Empty input shows nothing") + func empty() { + let layout = ConnectionTagsBadgeLayout(tags: []) + + #expect(layout.shown.isEmpty) + #expect(layout.overflow == 0) + #expect(layout.name == nil) + } + + @Test("A single tag shows its name and no overflow") + func single() { + let tag = ConnectionTag(name: "production") + let layout = ConnectionTagsBadgeLayout(tags: [tag]) + + #expect(layout.shown == [tag]) + #expect(layout.overflow == 0) + #expect(layout.name == "production") + } + + @Test("Up to three tags show dots only, no name and no overflow") + func threeShowDotsOnly() { + let layout = ConnectionTagsBadgeLayout(tags: tags(3)) + + #expect(layout.shown.count == 3) + #expect(layout.overflow == 0) + #expect(layout.name == nil) + } + + @Test("More than three tags cap at three dots and report the overflow count") + func overflow() { + let layout = ConnectionTagsBadgeLayout(tags: tags(5)) + + #expect(layout.shown.count == 3) + #expect(layout.overflow == 2) + #expect(layout.name == nil) + } +} diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index aaa49f98e..687e10e4b 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -43,6 +43,8 @@ Or manually: Download from [GitHub Releases](https://github.com/TableProApp/Tabl The welcome window has a sidebar of saved connections on the left and a detail panel on the right. Search is in the window toolbar; press `Cmd+F` to focus it. The toolbar `+` button starts a new connection, and the folder button creates a connection group. +Each connection shows its tag and group, so you can tell production from staging at a glance. The same labels appear in the connection switcher in the editor toolbar. + First launch ships a bundled Chinook sample database. Open it with one click to try TablePro without setting up a server. Reset the sample from **File > Reset Sample Database** any time. To make a real connection, click **Create connection** in the empty state (or the toolbar `+`). A chooser sheet appears with every supported database type grouped by category. Pick one and click **Continue**.