Skip to content

Commit dd29fe5

Browse files
authored
feat(datagrid): add native date/time picker for date columns (#1405) (#1410)
* feat(datagrid): add native date/time picker for date columns (#1405) * refactor(datagrid): centralize chevron-kind check and extract testable picker components (#1405) * fix(connections): add missing TableProPluginKit import for tunnel SSL rewrite (#1407) * refactor(datagrid): use native SwiftUI graphical DatePicker for date cells (#1405) * fix(datagrid): use .hourAndMinute on macOS and preserve original seconds in date picker (#1405) * feat(datagrid): custom SwiftUI calendar picker with seconds for date cells (#1405) * refactor(datagrid): extract testable CalendarMonth and add accessibility labels to date picker (#1405) --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 2f20aad commit dd29fe5

15 files changed

Lines changed: 748 additions & 38 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Cloudflare Tunnel: connect to a database behind Cloudflare Access by letting TablePro start and stop `cloudflared access tcp` for you, the same way it manages SSH tunnels. Configure it per connection with browser sign-in or a service token. Needs cloudflared installed (`brew install cloudflared`). (#1285)
1313
- Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304)
1414
- AWS IAM authentication for PostgreSQL and MySQL connections to RDS and Aurora. Pick AWS IAM in the connection's Authentication field and use an access key, a named AWS profile, or SSO. TablePro generates a fresh login token on every connect and reconnect, so you never paste an expiring token, and SSL is required automatically. (#1291)
15+
- Date, datetime, timestamp, and time cells show a date picker from the chevron button, so you can choose a value visually. Double-clicking still edits the cell as text, and the picker keeps the value's existing format, fractional seconds, and timezone offset. (#1405)
1516
- Pagination bar for table tabs with a rows-per-page menu (5, 10, 20, 100, 500, 1,000, All rows, or a custom size) and First, Previous, Next, and Last page buttons. (#1364)
1617
- Click the page indicator in the pagination bar to jump to a specific page. (#1364)
1718
- Pagination now appears for filtered tables whose total row count is unknown, so you can page through them instead of seeing only the first page. (#1364)

TablePro/Core/Database/DatabaseManager+Tunnel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import Foundation
77
import os
8+
import TableProPluginKit
89

910
// MARK: - Shared Tunnel Helpers
1011

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//
2+
// CalendarMonth.swift
3+
// TablePro
4+
//
5+
// Month grid layout for the data grid's date picker: leading blank cells,
6+
// the days of the month, and weekday header symbols ordered by the calendar's
7+
// first weekday.
8+
//
9+
10+
import Foundation
11+
12+
struct CalendarMonth: Equatable {
13+
let leadingBlanks: Int
14+
let dayCount: Int
15+
let days: [Date?]
16+
let weekdaySymbols: [String]
17+
18+
init?(containing date: Date, calendar: Calendar) {
19+
guard let monthInterval = calendar.dateInterval(of: .month, for: date),
20+
let dayCount = calendar.range(of: .day, in: .month, for: date)?.count else {
21+
return nil
22+
}
23+
24+
let firstWeekday = calendar.component(.weekday, from: monthInterval.start)
25+
let leadingBlanks = (firstWeekday - calendar.firstWeekday + 7) % 7
26+
27+
var days: [Date?] = Array(repeating: nil, count: leadingBlanks)
28+
for offset in 0..<dayCount {
29+
days.append(calendar.date(byAdding: .day, value: offset, to: monthInterval.start))
30+
}
31+
32+
let symbols = calendar.veryShortWeekdaySymbols
33+
let symbolOffset = calendar.firstWeekday - 1
34+
35+
self.leadingBlanks = leadingBlanks
36+
self.dayCount = dayCount
37+
self.days = days
38+
self.weekdaySymbols = Array(symbols[symbolOffset...] + symbols[..<symbolOffset])
39+
}
40+
}

TablePro/Core/Services/ColumnType.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,11 @@ enum ColumnType: Equatable {
7878

7979
var isTimeOnly: Bool {
8080
guard isDateType, let raw = rawType?.uppercased() else { return false }
81-
return raw == "TIME"
82-
|| raw == "TIMETZ"
83-
|| raw == "TIME WITHOUT TIME ZONE"
84-
|| raw == "TIME WITH TIME ZONE"
81+
let base = raw.prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces)
82+
return base == "TIME"
83+
|| base == "TIMETZ"
84+
|| base == "TIME WITHOUT TIME ZONE"
85+
|| base == "TIME WITH TIME ZONE"
8586
}
8687

8788
/// Whether this type represents long text that should use multi-line editor
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
//
2+
// DateEditingService.swift
3+
// TablePro
4+
//
5+
// Parses a database date/time string for editing and writes the edited value
6+
// back in the same shape. Distinct from DateFormattingService, which formats
7+
// for display using the user's locale and format preference.
8+
//
9+
10+
import Foundation
11+
12+
struct TemporalLayout: Equatable {
13+
let hasDate: Bool
14+
let hasTime: Bool
15+
let dateTimeSeparator: String
16+
let fractionalSeconds: String?
17+
let timeZoneSuffix: String?
18+
}
19+
20+
struct ParsedTemporalValue: Equatable {
21+
let date: Date
22+
let timeZone: TimeZone
23+
let layout: TemporalLayout
24+
}
25+
26+
enum TemporalComponents: Equatable {
27+
case dateOnly
28+
case timeOnly
29+
case dateAndTime
30+
}
31+
32+
enum DateEditingService {
33+
private static let pattern =
34+
#"^(?:(\d{4})-(\d{2})-(\d{2}))?(?:([ T])?(\d{2}):(\d{2}):(\d{2})(\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$"#
35+
36+
private static let matcher = try? NSRegularExpression(pattern: pattern)
37+
38+
private static let referenceDateComponents = (year: 2_000, month: 1, day: 1)
39+
40+
static func parse(_ rawValue: String?) -> ParsedTemporalValue? {
41+
guard let matcher, let raw = rawValue?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else {
42+
return nil
43+
}
44+
let range = NSRange(raw.startIndex..., in: raw)
45+
guard let match = matcher.firstMatch(in: raw, range: range) else { return nil }
46+
47+
func group(_ index: Int) -> String? {
48+
let groupRange = match.range(at: index)
49+
guard groupRange.location != NSNotFound, let swiftRange = Range(groupRange, in: raw) else {
50+
return nil
51+
}
52+
return String(raw[swiftRange])
53+
}
54+
55+
let year = group(1).flatMap(Int.init)
56+
let month = group(2).flatMap(Int.init)
57+
let day = group(3).flatMap(Int.init)
58+
let hour = group(5).flatMap(Int.init)
59+
let minute = group(6).flatMap(Int.init)
60+
let second = group(7).flatMap(Int.init)
61+
62+
let hasDate = year != nil && month != nil && day != nil
63+
let hasTime = hour != nil && minute != nil && second != nil
64+
guard hasDate || hasTime else { return nil }
65+
66+
let timeZoneSuffix = group(9)
67+
let timeZone = timeZoneSuffix.map(timeZone(fromSuffix:)) ?? .gmt
68+
69+
var components = DateComponents()
70+
components.year = hasDate ? year : referenceDateComponents.year
71+
components.month = hasDate ? month : referenceDateComponents.month
72+
components.day = hasDate ? day : referenceDateComponents.day
73+
components.hour = hasTime ? hour : 0
74+
components.minute = hasTime ? minute : 0
75+
components.second = hasTime ? second : 0
76+
77+
var calendar = Calendar(identifier: .gregorian)
78+
calendar.timeZone = timeZone
79+
guard let date = calendar.date(from: components) else { return nil }
80+
81+
let separator = group(4) ?? (hasDate && hasTime ? " " : "")
82+
let layout = TemporalLayout(
83+
hasDate: hasDate,
84+
hasTime: hasTime,
85+
dateTimeSeparator: separator,
86+
fractionalSeconds: group(8),
87+
timeZoneSuffix: timeZoneSuffix
88+
)
89+
return ParsedTemporalValue(date: date, timeZone: timeZone, layout: layout)
90+
}
91+
92+
static func string(from date: Date, like parsed: ParsedTemporalValue) -> String {
93+
var calendar = Calendar(identifier: .gregorian)
94+
calendar.timeZone = parsed.timeZone
95+
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
96+
let layout = parsed.layout
97+
98+
let datePart = dateString(from: components)
99+
let timePart = timeString(from: components) + (layout.fractionalSeconds ?? "")
100+
101+
var result: String
102+
if layout.hasDate && layout.hasTime {
103+
result = datePart + layout.dateTimeSeparator + timePart
104+
} else if layout.hasDate {
105+
result = datePart
106+
} else {
107+
result = timePart
108+
}
109+
if let suffix = layout.timeZoneSuffix {
110+
result += suffix
111+
}
112+
return result
113+
}
114+
115+
static func defaultString(from date: Date, columnType: ColumnType) -> String {
116+
var calendar = Calendar(identifier: .gregorian)
117+
calendar.timeZone = .gmt
118+
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
119+
120+
if case .date = columnType {
121+
return dateString(from: components)
122+
}
123+
if columnType.isTimeOnly {
124+
return timeString(from: components)
125+
}
126+
return dateString(from: components) + " " + timeString(from: components)
127+
}
128+
129+
static func components(for columnType: ColumnType) -> TemporalComponents {
130+
if case .date = columnType { return .dateOnly }
131+
if columnType.isTimeOnly { return .timeOnly }
132+
return .dateAndTime
133+
}
134+
135+
private static func dateString(from components: DateComponents) -> String {
136+
String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
137+
}
138+
139+
private static func timeString(from components: DateComponents) -> String {
140+
String(format: "%02d:%02d:%02d", components.hour ?? 0, components.minute ?? 0, components.second ?? 0)
141+
}
142+
143+
private static func timeZone(fromSuffix suffix: String) -> TimeZone {
144+
if suffix == "Z" { return .gmt }
145+
let sign = suffix.hasPrefix("-") ? -1 : 1
146+
let digits = suffix.dropFirst().filter(\.isNumber)
147+
let hours = Int(digits.prefix(2)) ?? 0
148+
let minutes = digits.count >= 4 ? (Int(digits.suffix(2)) ?? 0) : 0
149+
return TimeZone(secondsFromGMT: sign * (hours * 3_600 + minutes * 60)) ?? .gmt
150+
}
151+
}

TablePro/Views/Results/Cells/DataGridCellKind.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,14 @@ enum DataGridCellKind: Equatable {
1212
case boolean
1313
case json
1414
case blob
15+
case date
16+
17+
var showsChevron: Bool {
18+
switch self {
19+
case .dropdown, .boolean, .json, .blob, .date:
20+
return true
21+
case .text, .foreignKey:
22+
return false
23+
}
24+
}
1525
}

TablePro/Views/Results/Cells/DataGridCellRegistry.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ final class DataGridCellRegistry {
4545
if type.isBooleanType { return .boolean }
4646
if type.isJsonType { return .json }
4747
if type.isBlobType { return .blob }
48+
if type.isDateType { return .date }
4849
}
4950
return .text
5051
}

TablePro/Views/Results/Cells/DataGridCellView.swift

Lines changed: 23 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -283,42 +283,37 @@ final class DataGridCellView: NSView {
283283
}
284284

285285
private func computeAccessoryRect() -> NSRect {
286-
switch kind {
287-
case .text:
288-
return .zero
289-
case .foreignKey:
286+
if kind == .foreignKey {
290287
guard let raw = rawValue, !raw.isEmpty else { return .zero }
291288
let size = NSSize(width: 16, height: 16)
292289
let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width
293290
let y = (bounds.height - size.height) / 2
294291
return NSRect(x: x, y: y, width: size.width, height: size.height)
295-
case .dropdown, .boolean, .json, .blob:
296-
guard isEditableCell else { return .zero }
297-
let size = NSSize(width: 12, height: 14)
298-
let minRequired = size.width + 2 * DataGridMetrics.cellHorizontalInset
299-
guard bounds.width >= minRequired else { return .zero }
300-
let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width
301-
let y = (bounds.height - size.height) / 2
302-
return NSRect(x: x, y: y, width: size.width, height: size.height)
303292
}
293+
guard kind.showsChevron, isEditableCell else { return .zero }
294+
let size = NSSize(width: 12, height: 14)
295+
let minRequired = size.width + 2 * DataGridMetrics.cellHorizontalInset
296+
guard bounds.width >= minRequired else { return .zero }
297+
let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width
298+
let y = (bounds.height - size.height) / 2
299+
return NSRect(x: x, y: y, width: size.width, height: size.height)
304300
}
305301

306302
private func drawAccessory(in rect: NSRect) {
307303
guard !rect.isEmpty else { return }
308304
let image: CGImage?
309-
switch kind {
310-
case .text:
311-
return
312-
case .foreignKey:
305+
if kind == .foreignKey {
313306
image = onEmphasizedSelection ? Self.fkArrowEmphasized : Self.fkArrowNormal
314-
case .dropdown, .boolean, .json, .blob:
307+
} else if kind.showsChevron {
315308
if visualState.isDeleted {
316309
image = Self.chevronDisabled
317310
} else if onEmphasizedSelection {
318311
image = Self.chevronEmphasized
319312
} else {
320313
image = Self.chevronNormal
321314
}
315+
} else {
316+
return
322317
}
323318
guard let cgImage = image, let context = NSGraphicsContext.current?.cgContext else { return }
324319
context.saveGState()
@@ -337,21 +332,17 @@ final class DataGridCellView: NSView {
337332

338333
override func mouseDown(with event: NSEvent) {
339334
let point = convert(event.locationInWindow, from: nil)
340-
if !accessoryHitRect.isEmpty && accessoryHitRect.contains(point) {
341-
switch kind {
342-
case .foreignKey:
343-
accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex)
344-
return
345-
case .dropdown, .boolean, .json, .blob:
346-
guard !visualState.isDeleted else {
347-
super.mouseDown(with: event)
348-
return
349-
}
350-
accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex)
351-
return
352-
case .text:
353-
break
354-
}
335+
guard !accessoryHitRect.isEmpty, accessoryHitRect.contains(point) else {
336+
super.mouseDown(with: event)
337+
return
338+
}
339+
if kind == .foreignKey {
340+
accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex)
341+
return
342+
}
343+
if kind.showsChevron, !visualState.isDeleted {
344+
accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex)
345+
return
355346
}
356347
super.mouseDown(with: event)
357348
}

0 commit comments

Comments
 (0)