Skip to content

Commit e95f919

Browse files
authored
feat: extend ConnectionField with number, toggle, and stepper field types (#292)
* feat: extend ConnectionField with number, toggle, and stepper field types * fix: address PR review feedback for ConnectionField types
1 parent b9e66aa commit e95f919

File tree

7 files changed

+216
-25
lines changed

7 files changed

+216
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- `SettablePlugin` protocol in TableProPluginKit SDK: unified settings pattern for all plugins with automatic persistence via `loadSettings()`/`saveSettings()`, replacing duplicated boilerplate across export/import/driver plugins
1313
- Plugin UI/capability metadata: each driver plugin now self-declares brand color, connection mode, supported features, column types, URL schemes, and grouping strategy via the `DriverPlugin` protocol
1414
- Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel
15-
- Dynamic connection fields: connection form Advanced tab now renders fields from `DriverPlugin.additionalConnectionFields` instead of hardcoded per-database sections, with support for text, secure, and dropdown field types
15+
- Dynamic connection fields: connection form Advanced tab now renders fields from `DriverPlugin.additionalConnectionFields` instead of hardcoded per-database sections, with support for text, secure, dropdown, number, toggle, and stepper field types
1616
- Configurable plugin registry URL via `defaults write com.TablePro com.TablePro.customRegistryURL <url>` for enterprise/private registries
1717
- SQL import options (wrap in transaction, disable FK checks) now persist across launches
1818
- `needsRestart` banner persists across app quit/relaunch after plugin uninstall

Plugins/RedisDriverPlugin/RedisPlugin.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
2121
static let databaseDisplayName = "Redis"
2222
static let iconName = "cylinder.fill"
2323
static let defaultPort = 6379
24-
static let additionalConnectionFields: [ConnectionField] = []
24+
static let additionalConnectionFields: [ConnectionField] = [
25+
ConnectionField(
26+
id: "redisDatabase",
27+
label: String(localized: "Database Index"),
28+
defaultValue: "0",
29+
fieldType: .stepper(range: ConnectionField.IntRange(0...15))
30+
),
31+
]
2532
static let additionalDatabaseTypeIds: [String] = []
2633

2734
// MARK: - UI/Capability Metadata

Plugins/TableProPluginKit/ConnectionField.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,57 @@
11
import Foundation
22

33
public struct ConnectionField: Codable, Sendable {
4+
public struct IntRange: Codable, Sendable, Equatable {
5+
public let lowerBound: Int
6+
public let upperBound: Int
7+
8+
public init(_ range: ClosedRange<Int>) {
9+
self.lowerBound = range.lowerBound
10+
self.upperBound = range.upperBound
11+
}
12+
13+
public init(lowerBound: Int, upperBound: Int) {
14+
precondition(lowerBound <= upperBound, "IntRange: lowerBound must be <= upperBound")
15+
self.lowerBound = lowerBound
16+
self.upperBound = upperBound
17+
}
18+
19+
public var closedRange: ClosedRange<Int> { lowerBound...upperBound }
20+
21+
private enum CodingKeys: String, CodingKey {
22+
case lowerBound, upperBound
23+
}
24+
25+
public init(from decoder: Decoder) throws {
26+
let container = try decoder.container(keyedBy: CodingKeys.self)
27+
let lower = try container.decode(Int.self, forKey: .lowerBound)
28+
let upper = try container.decode(Int.self, forKey: .upperBound)
29+
guard lower <= upper else {
30+
throw DecodingError.dataCorrupted(
31+
DecodingError.Context(
32+
codingPath: container.codingPath,
33+
debugDescription: "IntRange lowerBound (\(lower)) must be <= upperBound (\(upper))"
34+
)
35+
)
36+
}
37+
self.lowerBound = lower
38+
self.upperBound = upper
39+
}
40+
41+
public func encode(to encoder: Encoder) throws {
42+
var container = encoder.container(keyedBy: CodingKeys.self)
43+
try container.encode(lowerBound, forKey: .lowerBound)
44+
try container.encode(upperBound, forKey: .upperBound)
45+
}
46+
}
47+
448
public enum FieldType: Codable, Sendable, Equatable {
549
case text
650
case secure
751
case dropdown(options: [DropdownOption])
52+
case number
53+
case toggle
54+
case stepper(range: IntRange)
855
}
956

1057
public struct DropdownOption: Codable, Sendable, Equatable {

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,8 +359,6 @@ enum DatabaseDriverFactory {
359359
switch connection.type {
360360
case .mongodb:
361361
fields["sslCACertPath"] = ssl.caCertificatePath
362-
case .redis:
363-
fields["redisDatabase"] = String(connection.redisDatabase ?? 0)
364362
default:
365363
break
366364
}

TablePro/Views/Connection/ConnectionFieldRow.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,37 @@ struct ConnectionFieldRow: View {
3030
Text(option.label).tag(option.value)
3131
}
3232
}
33+
case .number:
34+
TextField(
35+
field.label,
36+
text: Binding(
37+
get: { value },
38+
set: { newValue in
39+
value = String(newValue.unicodeScalars.filter {
40+
CharacterSet.decimalDigits.contains($0) || $0 == "-" || $0 == "."
41+
})
42+
}
43+
),
44+
prompt: field.placeholder.isEmpty ? nil : Text(field.placeholder)
45+
)
46+
case .toggle:
47+
Toggle(
48+
field.label,
49+
isOn: Binding(
50+
get: { value == "true" },
51+
set: { value = $0 ? "true" : "false" }
52+
)
53+
)
54+
case .stepper(let range):
55+
Stepper(
56+
value: Binding(
57+
get: { Int(value) ?? range.lowerBound },
58+
set: { value = String($0) }
59+
),
60+
in: range.closedRange
61+
) {
62+
Text("\(field.label): \(Int(value) ?? range.lowerBound)")
63+
}
3364
}
3465
}
3566
}

TablePro/Views/Connection/ConnectionFormView.swift

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -650,20 +650,6 @@ struct ConnectionFormView: View {
650650
}
651651
}
652652

653-
if type == .redis {
654-
Section("Redis") {
655-
Stepper(
656-
value: Binding(
657-
get: { Int(database) ?? 0 },
658-
set: { database = String($0) }
659-
),
660-
in: 0...15
661-
) {
662-
Text(String(localized: "Database Index: \(Int(database) ?? 0)"))
663-
}
664-
}
665-
}
666-
667653
Section(String(localized: "Startup Commands")) {
668654
StartupCommandsEditor(text: $startupCommands)
669655
.frame(height: 80)
@@ -877,17 +863,20 @@ struct ConnectionFormView: View {
877863

878864
// Load additional fields from connection
879865
additionalFieldValues = existing.additionalFields
866+
867+
// Migrate legacy Redis database index before default seeding
868+
if existing.type == .redis,
869+
additionalFieldValues["redisDatabase"] == nil,
870+
let rdb = existing.redisDatabase {
871+
additionalFieldValues["redisDatabase"] = String(rdb)
872+
}
873+
880874
for field in PluginManager.shared.additionalConnectionFields(for: existing.type) {
881875
if additionalFieldValues[field.id] == nil, let defaultValue = field.defaultValue {
882876
additionalFieldValues[field.id] = defaultValue
883877
}
884878
}
885879

886-
// Load Redis settings (special case)
887-
if existing.type == .redis, let rdb = existing.redisDatabase {
888-
database = String(rdb)
889-
}
890-
891880
// Load startup commands
892881
startupCommands = existing.startupCommands ?? ""
893882
usePgpass = existing.usePgpass
@@ -965,7 +954,9 @@ struct ConnectionFormView: View {
965954
groupId: selectedGroupId,
966955
safeModeLevel: safeModeLevel,
967956
aiPolicy: aiPolicy,
968-
redisDatabase: type == .redis ? (Int(database) ?? 0) : nil,
957+
redisDatabase: type == .redis
958+
? Int(additionalFieldValues["redisDatabase"] ?? "0")
959+
: nil,
969960
startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
970961
? nil : startupCommands,
971962
additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields
@@ -1108,7 +1099,9 @@ struct ConnectionFormView: View {
11081099
color: connectionColor,
11091100
tagId: selectedTagId,
11101101
groupId: selectedGroupId,
1111-
redisDatabase: type == .redis ? (Int(database) ?? 0) : nil,
1102+
redisDatabase: type == .redis
1103+
? Int(additionalFieldValues["redisDatabase"] ?? "0")
1104+
: nil,
11121105
startupCommands: startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
11131106
? nil : startupCommands,
11141107
additionalFields: finalAdditionalFields.isEmpty ? nil : finalAdditionalFields
@@ -1248,6 +1241,9 @@ struct ConnectionFormView: View {
12481241
if let authSourceValue = parsed.authSource, !authSourceValue.isEmpty {
12491242
additionalFieldValues["mongoAuthSource"] = authSourceValue
12501243
}
1244+
if parsed.type == .redis, !parsed.database.isEmpty {
1245+
additionalFieldValues["redisDatabase"] = parsed.database
1246+
}
12511247
if let connectionName = parsed.connectionName, !connectionName.isEmpty {
12521248
name = connectionName
12531249
} else if name.isEmpty {

TableProTests/Core/Plugins/ConnectionFieldTests.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,116 @@ struct ConnectionFieldTests {
153153
#expect(decoded.id == field.id)
154154
#expect(decoded.fieldType == .text)
155155
}
156+
157+
// MARK: - IntRange
158+
159+
@Test("IntRange init from ClosedRange")
160+
func intRangeFromClosedRange() {
161+
let range = ConnectionField.IntRange(0...15)
162+
#expect(range.lowerBound == 0)
163+
#expect(range.upperBound == 15)
164+
}
165+
166+
@Test("IntRange closedRange round-trip")
167+
func intRangeClosedRangeRoundTrip() {
168+
let range = ConnectionField.IntRange(3...42)
169+
#expect(range.closedRange == 3...42)
170+
}
171+
172+
@Test("IntRange init from bounds")
173+
func intRangeFromBounds() {
174+
let range = ConnectionField.IntRange(lowerBound: 1, upperBound: 100)
175+
#expect(range.lowerBound == 1)
176+
#expect(range.upperBound == 100)
177+
#expect(range.closedRange == 1...100)
178+
}
179+
180+
@Test("IntRange decoding rejects invalid bounds")
181+
func intRangeDecodingRejectsInvalidBounds() throws {
182+
let json = #"{"lowerBound":10,"upperBound":0}"#
183+
let data = Data(json.utf8)
184+
#expect(throws: DecodingError.self) {
185+
try JSONDecoder().decode(ConnectionField.IntRange.self, from: data)
186+
}
187+
}
188+
189+
// MARK: - isSecure for new types
190+
191+
@Test("isSecure is false for .number")
192+
func isSecureForNumber() {
193+
let field = ConnectionField(id: "port", label: "Port", fieldType: .number)
194+
#expect(field.isSecure == false)
195+
}
196+
197+
@Test("isSecure is false for .toggle")
198+
func isSecureForToggle() {
199+
let field = ConnectionField(id: "flag", label: "Flag", fieldType: .toggle)
200+
#expect(field.isSecure == false)
201+
}
202+
203+
@Test("isSecure is false for .stepper")
204+
func isSecureForStepper() {
205+
let range = ConnectionField.IntRange(0...15)
206+
let field = ConnectionField(id: "db", label: "DB", fieldType: .stepper(range: range))
207+
#expect(field.isSecure == false)
208+
}
209+
210+
// MARK: - Codable round-trips for new types
211+
212+
@Test("Codable round-trip for .number field")
213+
func codableNumber() throws {
214+
let field = ConnectionField(
215+
id: "port",
216+
label: "Port",
217+
placeholder: "3306",
218+
defaultValue: "3306",
219+
fieldType: .number
220+
)
221+
222+
let data = try JSONEncoder().encode(field)
223+
let decoded = try JSONDecoder().decode(ConnectionField.self, from: data)
224+
225+
#expect(decoded.id == field.id)
226+
#expect(decoded.label == field.label)
227+
#expect(decoded.placeholder == field.placeholder)
228+
#expect(decoded.defaultValue == field.defaultValue)
229+
#expect(decoded.fieldType == .number)
230+
}
231+
232+
@Test("Codable round-trip for .toggle field")
233+
func codableToggle() throws {
234+
let field = ConnectionField(
235+
id: "compress",
236+
label: "Compress",
237+
defaultValue: "false",
238+
fieldType: .toggle
239+
)
240+
241+
let data = try JSONEncoder().encode(field)
242+
let decoded = try JSONDecoder().decode(ConnectionField.self, from: data)
243+
244+
#expect(decoded.id == field.id)
245+
#expect(decoded.label == field.label)
246+
#expect(decoded.defaultValue == "false")
247+
#expect(decoded.fieldType == .toggle)
248+
}
249+
250+
@Test("Codable round-trip for .stepper field with IntRange")
251+
func codableStepper() throws {
252+
let range = ConnectionField.IntRange(0...15)
253+
let field = ConnectionField(
254+
id: "redisDatabase",
255+
label: "Database Index",
256+
defaultValue: "0",
257+
fieldType: .stepper(range: range)
258+
)
259+
260+
let data = try JSONEncoder().encode(field)
261+
let decoded = try JSONDecoder().decode(ConnectionField.self, from: data)
262+
263+
#expect(decoded.id == field.id)
264+
#expect(decoded.label == field.label)
265+
#expect(decoded.defaultValue == "0")
266+
#expect(decoded.fieldType == .stepper(range: range))
267+
}
156268
}

0 commit comments

Comments
 (0)