From fa10c73f3c2364b65779e0c3a8ad0940cbaa2abd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 15:16:56 +0700 Subject: [PATCH 1/7] feat(datagrid): add native date/time picker for date columns (#1405) --- CHANGELOG.md | 1 + TablePro/Core/Services/ColumnType.swift | 9 +- .../Formatting/DateEditingService.swift | 139 ++++++++++++++++++ .../Results/Cells/DataGridCellKind.swift | 1 + .../Results/Cells/DataGridCellRegistry.swift | 1 + .../Results/Cells/DataGridCellView.swift | 6 +- .../Results/DateTimePickerContentView.swift | 104 +++++++++++++ .../Extensions/DataGridView+Click.swift | 2 + .../Extensions/DataGridView+Popovers.swift | 36 +++++ .../Core/Services/ColumnTypeTests.swift | 18 ++- .../Services/DateEditingServiceTests.swift | 120 +++++++++++++++ docs/features/data-grid.mdx | 2 +- 12 files changed, 430 insertions(+), 9 deletions(-) create mode 100644 TablePro/Core/Services/Formatting/DateEditingService.swift create mode 100644 TablePro/Views/Results/DateTimePickerContentView.swift create mode 100644 TableProTests/Core/Services/DateEditingServiceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 474857d6c..66ee8e6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - 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) - 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) +- Date, datetime, timestamp, and time cells show a calendar and clock 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) ### Fixed diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index ff60de31e..67c605cf3 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -78,10 +78,11 @@ enum ColumnType: Equatable { var isTimeOnly: Bool { guard isDateType, let raw = rawType?.uppercased() else { return false } - return raw == "TIME" - || raw == "TIMETZ" - || raw == "TIME WITHOUT TIME ZONE" - || raw == "TIME WITH TIME ZONE" + let base = raw.prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces) + return base == "TIME" + || base == "TIMETZ" + || base == "TIME WITHOUT TIME ZONE" + || base == "TIME WITH TIME ZONE" } /// Whether this type represents long text that should use multi-line editor diff --git a/TablePro/Core/Services/Formatting/DateEditingService.swift b/TablePro/Core/Services/Formatting/DateEditingService.swift new file mode 100644 index 000000000..a514a11f9 --- /dev/null +++ b/TablePro/Core/Services/Formatting/DateEditingService.swift @@ -0,0 +1,139 @@ +// +// DateEditingService.swift +// TablePro +// +// Parses a database date/time string for editing and writes the edited value +// back in the same shape. Distinct from DateFormattingService, which formats +// for display using the user's locale and format preference. +// + +import Foundation + +struct TemporalLayout: Equatable { + let hasDate: Bool + let hasTime: Bool + let dateTimeSeparator: String + let fractionalSeconds: String? + let timeZoneSuffix: String? +} + +struct ParsedTemporalValue: Equatable { + let date: Date + let timeZone: TimeZone + let layout: TemporalLayout +} + +enum DateEditingService { + private static let pattern = + #"^(?:(\d{4})-(\d{2})-(\d{2}))?(?:([ T])?(\d{2}):(\d{2}):(\d{2})(\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$"# + + private static let matcher = try? NSRegularExpression(pattern: pattern) + + private static let referenceDateComponents = (year: 2_000, month: 1, day: 1) + + static func parse(_ rawValue: String?) -> ParsedTemporalValue? { + guard let matcher, let raw = rawValue?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else { + return nil + } + let range = NSRange(raw.startIndex..., in: raw) + guard let match = matcher.firstMatch(in: raw, range: range) else { return nil } + + func group(_ index: Int) -> String? { + let groupRange = match.range(at: index) + guard groupRange.location != NSNotFound, let swiftRange = Range(groupRange, in: raw) else { + return nil + } + return String(raw[swiftRange]) + } + + let year = group(1).flatMap(Int.init) + let month = group(2).flatMap(Int.init) + let day = group(3).flatMap(Int.init) + let hour = group(5).flatMap(Int.init) + let minute = group(6).flatMap(Int.init) + let second = group(7).flatMap(Int.init) + + let hasDate = year != nil && month != nil && day != nil + let hasTime = hour != nil && minute != nil && second != nil + guard hasDate || hasTime else { return nil } + + let timeZoneSuffix = group(9) + let timeZone = timeZoneSuffix.map(timeZone(fromSuffix:)) ?? .gmt + + var components = DateComponents() + components.year = hasDate ? year : referenceDateComponents.year + components.month = hasDate ? month : referenceDateComponents.month + components.day = hasDate ? day : referenceDateComponents.day + components.hour = hasTime ? hour : 0 + components.minute = hasTime ? minute : 0 + components.second = hasTime ? second : 0 + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + guard let date = calendar.date(from: components) else { return nil } + + let separator = group(4) ?? (hasDate && hasTime ? " " : "") + let layout = TemporalLayout( + hasDate: hasDate, + hasTime: hasTime, + dateTimeSeparator: separator, + fractionalSeconds: group(8), + timeZoneSuffix: timeZoneSuffix + ) + return ParsedTemporalValue(date: date, timeZone: timeZone, layout: layout) + } + + static func string(from date: Date, like parsed: ParsedTemporalValue) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = parsed.timeZone + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + let layout = parsed.layout + + let datePart = dateString(from: components) + let timePart = timeString(from: components) + (layout.fractionalSeconds ?? "") + + var result: String + if layout.hasDate && layout.hasTime { + result = datePart + layout.dateTimeSeparator + timePart + } else if layout.hasDate { + result = datePart + } else { + result = timePart + } + if let suffix = layout.timeZoneSuffix { + result += suffix + } + return result + } + + static func defaultString(from date: Date, columnType: ColumnType) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + + if case .date = columnType { + return dateString(from: components) + } + if columnType.isTimeOnly { + return timeString(from: components) + } + return dateString(from: components) + " " + timeString(from: components) + } + + private static func dateString(from components: DateComponents) -> String { + String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + + private static func timeString(from components: DateComponents) -> String { + String(format: "%02d:%02d:%02d", components.hour ?? 0, components.minute ?? 0, components.second ?? 0) + } + + private static func timeZone(fromSuffix suffix: String) -> TimeZone { + if suffix == "Z" { return .gmt } + let sign = suffix.hasPrefix("-") ? -1 : 1 + let digits = suffix.dropFirst().filter(\.isNumber) + let hours = Int(digits.prefix(2)) ?? 0 + let minutes = digits.count >= 4 ? (Int(digits.suffix(2)) ?? 0) : 0 + return TimeZone(secondsFromGMT: sign * (hours * 3_600 + minutes * 60)) ?? .gmt + } +} diff --git a/TablePro/Views/Results/Cells/DataGridCellKind.swift b/TablePro/Views/Results/Cells/DataGridCellKind.swift index aaa30fde4..5e76174c6 100644 --- a/TablePro/Views/Results/Cells/DataGridCellKind.swift +++ b/TablePro/Views/Results/Cells/DataGridCellKind.swift @@ -12,4 +12,5 @@ enum DataGridCellKind: Equatable { case boolean case json case blob + case date } diff --git a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift index 394f07db4..439cce0b6 100644 --- a/TablePro/Views/Results/Cells/DataGridCellRegistry.swift +++ b/TablePro/Views/Results/Cells/DataGridCellRegistry.swift @@ -45,6 +45,7 @@ final class DataGridCellRegistry { if type.isBooleanType { return .boolean } if type.isJsonType { return .json } if type.isBlobType { return .blob } + if type.isDateType { return .date } } return .text } diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index a336e4c78..608da63ef 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -292,7 +292,7 @@ final class DataGridCellView: NSView { let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width let y = (bounds.height - size.height) / 2 return NSRect(x: x, y: y, width: size.width, height: size.height) - case .dropdown, .boolean, .json, .blob: + case .dropdown, .boolean, .json, .blob, .date: guard isEditableCell else { return .zero } let size = NSSize(width: 12, height: 14) let minRequired = size.width + 2 * DataGridMetrics.cellHorizontalInset @@ -311,7 +311,7 @@ final class DataGridCellView: NSView { return case .foreignKey: image = onEmphasizedSelection ? Self.fkArrowEmphasized : Self.fkArrowNormal - case .dropdown, .boolean, .json, .blob: + case .dropdown, .boolean, .json, .blob, .date: if visualState.isDeleted { image = Self.chevronDisabled } else if onEmphasizedSelection { @@ -342,7 +342,7 @@ final class DataGridCellView: NSView { case .foreignKey: accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex) return - case .dropdown, .boolean, .json, .blob: + case .dropdown, .boolean, .json, .blob, .date: guard !visualState.isDeleted else { super.mouseDown(with: event) return diff --git a/TablePro/Views/Results/DateTimePickerContentView.swift b/TablePro/Views/Results/DateTimePickerContentView.swift new file mode 100644 index 000000000..cffd59202 --- /dev/null +++ b/TablePro/Views/Results/DateTimePickerContentView.swift @@ -0,0 +1,104 @@ +// +// DateTimePickerContentView.swift +// TablePro +// +// Graphical calendar/clock popover for editing date, datetime, timestamp, +// and time columns in the data grid. +// + +import AppKit +import SwiftUI + +struct DateTimePickerContentView: View { + let initialDate: Date + let elements: NSDatePicker.ElementFlags + let timeZone: TimeZone + let onCommit: (Date) -> Void + let onDismiss: () -> Void + + @State private var date: Date + + init( + initialDate: Date, + elements: NSDatePicker.ElementFlags, + timeZone: TimeZone, + onCommit: @escaping (Date) -> Void, + onDismiss: @escaping () -> Void + ) { + self.initialDate = initialDate + self.elements = elements + self.timeZone = timeZone + self.onCommit = onCommit + self.onDismiss = onDismiss + self._date = State(initialValue: initialDate) + } + + var body: some View { + VStack(spacing: 0) { + GraphicalDatePicker(date: $date, elements: elements, timeZone: timeZone) + .fixedSize() + .padding(12) + + Divider() + + HStack { + Spacer() + Button("Cancel") { onDismiss() } + .keyboardShortcut(.cancelAction) + Button("OK") { + onCommit(date) + onDismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .fixedSize() + } +} + +private struct GraphicalDatePicker: NSViewRepresentable { + @Binding var date: Date + let elements: NSDatePicker.ElementFlags + let timeZone: TimeZone + + func makeNSView(context: Context) -> NSDatePicker { + let picker = NSDatePicker() + picker.datePickerStyle = .clockAndCalendar + picker.datePickerMode = .single + picker.datePickerElements = elements + picker.calendar = Calendar(identifier: .gregorian) + picker.timeZone = timeZone + picker.target = context.coordinator + picker.action = #selector(Coordinator.dateChanged(_:)) + picker.dateValue = date + picker.sizeToFit() + return picker + } + + func updateNSView(_ picker: NSDatePicker, context: Context) { + context.coordinator.date = $date + picker.timeZone = timeZone + if picker.dateValue != date { + picker.dateValue = date + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(date: $date) + } + + @MainActor + final class Coordinator: NSObject { + var date: Binding + + init(date: Binding) { + self.date = date + } + + @objc func dateChanged(_ sender: NSDatePicker) { + date.wrappedValue = sender.dateValue + } + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index d1f560372..0b90f03a7 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -99,6 +99,8 @@ extension TableViewCoordinator { showJSONEditorPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } else if columnType.isBlobType { showBlobEditorPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) + } else if columnType.isDateType { + showDateTimePickerPopover(tableView: tableView, row: row, column: column, columnIndex: columnIndex) } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 7f212ed11..c0b5789d5 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -176,6 +176,42 @@ extension TableViewCoordinator { } } + func showDateTimePickerPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columnTypes.count else { return } + guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } + + let columnType = tableRows.columnTypes[columnIndex] + let parsed = DateEditingService.parse(cellValue(at: row, column: columnIndex)) + let initialDate = parsed?.date ?? Date() + let timeZone = parsed?.timeZone ?? .gmt + let elements = datePickerElements(for: columnType) + + let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) + PopoverPresenter.show( + relativeTo: cellRect, + of: tableView + ) { [weak self] dismiss in + DateTimePickerContentView( + initialDate: initialDate, + elements: elements, + timeZone: timeZone, + onCommit: { picked in + let newValue = parsed.map { DateEditingService.string(from: picked, like: $0) } + ?? DateEditingService.defaultString(from: picked, columnType: columnType) + self?.commitPopoverEdit(row: row, columnIndex: columnIndex, newValue: newValue) + }, + onDismiss: dismiss + ) + } + } + + private func datePickerElements(for columnType: ColumnType) -> NSDatePicker.ElementFlags { + if case .date = columnType { return .yearMonthDay } + if columnType.isTimeOnly { return .hourMinuteSecond } + return [.yearMonthDay, .hourMinuteSecond] + } + func showEnumPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } let tableRows = tableRowsProvider() diff --git a/TableProTests/Core/Services/ColumnTypeTests.swift b/TableProTests/Core/Services/ColumnTypeTests.swift index 62c2bb744..072acc09d 100644 --- a/TableProTests/Core/Services/ColumnTypeTests.swift +++ b/TableProTests/Core/Services/ColumnTypeTests.swift @@ -7,9 +7,10 @@ import Foundation import TableProPluginKit -@testable import TablePro import Testing +@testable import TablePro + @Suite("Column Type") struct ColumnTypeTests { // MARK: - isEnumType / isSetType Properties @@ -121,6 +122,21 @@ struct ColumnTypeTests { #expect(!ColumnType.text(rawType: "TIME").isTimeOnly) } + @Test("timestamp with TIME and precision reports isTimeOnly true") + func timeWithPrecisionIsTimeOnly() { + #expect(ColumnType.timestamp(rawType: "TIME(6)").isTimeOnly) + } + + @Test("timestamp with TIMESTAMP and precision reports isTimeOnly false") + func timestampWithPrecisionIsNotTimeOnly() { + #expect(!ColumnType.timestamp(rawType: "TIMESTAMP(6)").isTimeOnly) + } + + @Test("timestamptz reports isTimeOnly false") + func timestamptzIsNotTimeOnly() { + #expect(!ColumnType.timestamp(rawType: "TIMESTAMPTZ").isTimeOnly) + } + // MARK: - enumValues Property @Test("enumType with values returns those values") diff --git a/TableProTests/Core/Services/DateEditingServiceTests.swift b/TableProTests/Core/Services/DateEditingServiceTests.swift new file mode 100644 index 000000000..bb878846b --- /dev/null +++ b/TableProTests/Core/Services/DateEditingServiceTests.swift @@ -0,0 +1,120 @@ +// +// DateEditingServiceTests.swift +// TableProTests +// +// Tests for parsing database date/time strings and writing edited values back +// in the same shape, preserving fractional seconds and timezone offsets. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Date Editing") +struct DateEditingServiceTests { + @Test("MySQL datetime round-trips unchanged") + func mysqlDatetimeRoundTrip() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15 09:30:00")) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "2024-03-15 09:30:00") + } + + @Test("ISO 8601 T separator is preserved") + func isoSeparatorPreserved() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15T09:30:00")) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "2024-03-15T09:30:00") + } + + @Test("UTC Z suffix round-trips") + func zuluRoundTrip() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15T09:30:00Z")) + #expect(parsed.timeZone.secondsFromGMT() == 0) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "2024-03-15T09:30:00Z") + } + + @Test("timezone offset is preserved verbatim") + func offsetPreserved() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15T09:30:00+05:30")) + #expect(parsed.timeZone.secondsFromGMT() == 19_800) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "2024-03-15T09:30:00+05:30") + } + + @Test("two-digit timezone offset is preserved") + func shortOffsetPreserved() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15 09:30:00+00")) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "2024-03-15 09:30:00+00") + } + + @Test("fractional seconds are preserved") + func fractionalSecondsPreserved() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15 09:30:00.123456")) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "2024-03-15 09:30:00.123456") + } + + @Test("fractional seconds and offset preserved together") + func fractionAndOffsetPreserved() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15T09:30:00.123456+05:30")) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "2024-03-15T09:30:00.123456+05:30") + } + + @Test("date-only round-trips without a time component") + func dateOnlyRoundTrip() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15")) + #expect(parsed.layout.hasTime == false) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "2024-03-15") + } + + @Test("time-only round-trips without a date component") + func timeOnlyRoundTrip() throws { + let parsed = try #require(DateEditingService.parse("09:30:45")) + #expect(parsed.layout.hasDate == false) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "09:30:45") + } + + @Test("time-only fractional seconds are preserved") + func timeOnlyFractionPreserved() throws { + let parsed = try #require(DateEditingService.parse("09:30:45.5")) + #expect(DateEditingService.string(from: parsed.date, like: parsed) == "09:30:45.5") + } + + @Test("advancing the date keeps fractional seconds") + func editKeepsFraction() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15 09:30:00.123456")) + let nextDay = parsed.date.addingTimeInterval(86_400) + #expect(DateEditingService.string(from: nextDay, like: parsed) == "2024-03-16 09:30:00.123456") + } + + @Test("null, empty, and whitespace parse to nil") + func nullParsesToNil() { + #expect(DateEditingService.parse(nil) == nil) + #expect(DateEditingService.parse("") == nil) + #expect(DateEditingService.parse(" ") == nil) + } + + @Test("unparseable values parse to nil") + func unparseableParsesToNil() { + #expect(DateEditingService.parse("not a date") == nil) + #expect(DateEditingService.parse("2024") == nil) + #expect(DateEditingService.parse("Z") == nil) + } + + @Test("default string for a date column emits date only") + func defaultDateString() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15 09:30:45")) + #expect(DateEditingService.defaultString(from: parsed.date, columnType: .date(rawType: "DATE")) == "2024-03-15") + } + + @Test("default string for a timestamp column emits date and time") + func defaultTimestampString() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15 09:30:45")) + let value = DateEditingService.defaultString(from: parsed.date, columnType: .timestamp(rawType: "TIMESTAMP")) + #expect(value == "2024-03-15 09:30:45") + } + + @Test("default string for a time column emits time only") + func defaultTimeString() throws { + let parsed = try #require(DateEditingService.parse("2024-03-15 09:30:45")) + let value = DateEditingService.defaultString(from: parsed.date, columnType: .timestamp(rawType: "TIME")) + #expect(value == "09:30:45") + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 50d3e6127..335f10b1c 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -103,7 +103,7 @@ To edit a cell: ### Date/Time Picker -Date, datetime, timestamp, and time columns open a native date picker instead of a text field. The picker adapts to the column type (`DATE`: year/month/day, `DATETIME`/`TIMESTAMP`: adds hour/minute/second, `TIME`: hour/minute/second only). Outputs in `YYYY-MM-DD HH:MM:SS` format. If the existing value cannot be parsed, it defaults to now. +Date, datetime, timestamp, and time columns have a native calendar and clock picker. Double-click the cell to edit the value as text, or click the chevron button to open the picker. The picker adapts to the column type: `DATE` shows year, month, and day; `DATETIME` and `TIMESTAMP` add hour, minute, and second; `TIME` shows time only. Picking a value keeps the cell's existing format, including fractional seconds and any timezone offset; an empty or unparseable cell starts at the current date and time. To set the current timestamp or `NULL`, right-click the cell and use Set Value. {/* Screenshot: Date picker for editing date columns */} From 04338c87cfa49ccf41ca691f9fe545ee9bcd0a3a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 15:32:27 +0700 Subject: [PATCH 2/7] refactor(datagrid): centralize chevron-kind check and extract testable picker components (#1405) --- .../Formatting/DateEditingService.swift | 12 ++++ .../Results/Cells/DataGridCellKind.swift | 9 +++ .../Results/Cells/DataGridCellView.swift | 55 ++++++++----------- .../Extensions/DataGridView+Popovers.swift | 8 ++- .../Services/DateEditingServiceTests.swift | 20 +++++++ 5 files changed, 69 insertions(+), 35 deletions(-) diff --git a/TablePro/Core/Services/Formatting/DateEditingService.swift b/TablePro/Core/Services/Formatting/DateEditingService.swift index a514a11f9..469b6642c 100644 --- a/TablePro/Core/Services/Formatting/DateEditingService.swift +++ b/TablePro/Core/Services/Formatting/DateEditingService.swift @@ -23,6 +23,12 @@ struct ParsedTemporalValue: Equatable { let layout: TemporalLayout } +enum TemporalComponents: Equatable { + case dateOnly + case timeOnly + case dateAndTime +} + enum DateEditingService { private static let pattern = #"^(?:(\d{4})-(\d{2})-(\d{2}))?(?:([ T])?(\d{2}):(\d{2}):(\d{2})(\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$"# @@ -120,6 +126,12 @@ enum DateEditingService { return dateString(from: components) + " " + timeString(from: components) } + static func components(for columnType: ColumnType) -> TemporalComponents { + if case .date = columnType { return .dateOnly } + if columnType.isTimeOnly { return .timeOnly } + return .dateAndTime + } + private static func dateString(from components: DateComponents) -> String { String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) } diff --git a/TablePro/Views/Results/Cells/DataGridCellKind.swift b/TablePro/Views/Results/Cells/DataGridCellKind.swift index 5e76174c6..d44c56999 100644 --- a/TablePro/Views/Results/Cells/DataGridCellKind.swift +++ b/TablePro/Views/Results/Cells/DataGridCellKind.swift @@ -13,4 +13,13 @@ enum DataGridCellKind: Equatable { case json case blob case date + + var showsChevron: Bool { + switch self { + case .dropdown, .boolean, .json, .blob, .date: + return true + case .text, .foreignKey: + return false + } + } } diff --git a/TablePro/Views/Results/Cells/DataGridCellView.swift b/TablePro/Views/Results/Cells/DataGridCellView.swift index 608da63ef..626be8db0 100644 --- a/TablePro/Views/Results/Cells/DataGridCellView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellView.swift @@ -283,35 +283,28 @@ final class DataGridCellView: NSView { } private func computeAccessoryRect() -> NSRect { - switch kind { - case .text: - return .zero - case .foreignKey: + if kind == .foreignKey { guard let raw = rawValue, !raw.isEmpty else { return .zero } let size = NSSize(width: 16, height: 16) let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width let y = (bounds.height - size.height) / 2 return NSRect(x: x, y: y, width: size.width, height: size.height) - case .dropdown, .boolean, .json, .blob, .date: - guard isEditableCell else { return .zero } - let size = NSSize(width: 12, height: 14) - let minRequired = size.width + 2 * DataGridMetrics.cellHorizontalInset - guard bounds.width >= minRequired else { return .zero } - let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width - let y = (bounds.height - size.height) / 2 - return NSRect(x: x, y: y, width: size.width, height: size.height) } + guard kind.showsChevron, isEditableCell else { return .zero } + let size = NSSize(width: 12, height: 14) + let minRequired = size.width + 2 * DataGridMetrics.cellHorizontalInset + guard bounds.width >= minRequired else { return .zero } + let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width + let y = (bounds.height - size.height) / 2 + return NSRect(x: x, y: y, width: size.width, height: size.height) } private func drawAccessory(in rect: NSRect) { guard !rect.isEmpty else { return } let image: CGImage? - switch kind { - case .text: - return - case .foreignKey: + if kind == .foreignKey { image = onEmphasizedSelection ? Self.fkArrowEmphasized : Self.fkArrowNormal - case .dropdown, .boolean, .json, .blob, .date: + } else if kind.showsChevron { if visualState.isDeleted { image = Self.chevronDisabled } else if onEmphasizedSelection { @@ -319,6 +312,8 @@ final class DataGridCellView: NSView { } else { image = Self.chevronNormal } + } else { + return } guard let cgImage = image, let context = NSGraphicsContext.current?.cgContext else { return } context.saveGState() @@ -337,21 +332,17 @@ final class DataGridCellView: NSView { override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) - if !accessoryHitRect.isEmpty && accessoryHitRect.contains(point) { - switch kind { - case .foreignKey: - accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex) - return - case .dropdown, .boolean, .json, .blob, .date: - guard !visualState.isDeleted else { - super.mouseDown(with: event) - return - } - accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex) - return - case .text: - break - } + guard !accessoryHitRect.isEmpty, accessoryHitRect.contains(point) else { + super.mouseDown(with: event) + return + } + if kind == .foreignKey { + accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex) + return + } + if kind.showsChevron, !visualState.isDeleted { + accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex) + return } super.mouseDown(with: event) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index c0b5789d5..585f600e5 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -207,9 +207,11 @@ extension TableViewCoordinator { } private func datePickerElements(for columnType: ColumnType) -> NSDatePicker.ElementFlags { - if case .date = columnType { return .yearMonthDay } - if columnType.isTimeOnly { return .hourMinuteSecond } - return [.yearMonthDay, .hourMinuteSecond] + switch DateEditingService.components(for: columnType) { + case .dateOnly: return .yearMonthDay + case .timeOnly: return .hourMinuteSecond + case .dateAndTime: return [.yearMonthDay, .hourMinuteSecond] + } } func showEnumPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { diff --git a/TableProTests/Core/Services/DateEditingServiceTests.swift b/TableProTests/Core/Services/DateEditingServiceTests.swift index bb878846b..ba4140255 100644 --- a/TableProTests/Core/Services/DateEditingServiceTests.swift +++ b/TableProTests/Core/Services/DateEditingServiceTests.swift @@ -117,4 +117,24 @@ struct DateEditingServiceTests { let value = DateEditingService.defaultString(from: parsed.date, columnType: .timestamp(rawType: "TIME")) #expect(value == "09:30:45") } + + @Test("date column edits date components only") + func componentsForDate() { + #expect(DateEditingService.components(for: .date(rawType: "DATE")) == .dateOnly) + } + + @Test("time column edits time components only") + func componentsForTime() { + #expect(DateEditingService.components(for: .timestamp(rawType: "TIME")) == .timeOnly) + } + + @Test("time column with precision edits time components only") + func componentsForTimeWithPrecision() { + #expect(DateEditingService.components(for: .timestamp(rawType: "TIME(6)")) == .timeOnly) + } + + @Test("timestamp column edits date and time components") + func componentsForTimestamp() { + #expect(DateEditingService.components(for: .timestamp(rawType: "TIMESTAMP")) == .dateAndTime) + } } From 0e302ca18e5154bab02e0a74f2acb6673238c94e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 16:00:28 +0700 Subject: [PATCH 3/7] fix(connections): add missing TableProPluginKit import for tunnel SSL rewrite (#1407) --- TablePro/Core/Database/DatabaseManager+Tunnel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 448981844..39daef5c0 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -5,6 +5,7 @@ import Foundation import os +import TableProPluginKit // MARK: - Shared Tunnel Helpers From 287ca471a0f18fe2c2276b8519bc5c65d0cb8d35 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 16:13:55 +0700 Subject: [PATCH 4/7] refactor(datagrid): use native SwiftUI graphical DatePicker for date cells (#1405) --- CHANGELOG.md | 2 +- .../Results/DateTimePickerContentView.swift | 71 ++++++------------- .../Extensions/DataGridView+Popovers.swift | 12 +--- docs/features/data-grid.mdx | 2 +- 4 files changed, 24 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66ee8e6c6..23e1d18ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) - 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) - 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) -- Date, datetime, timestamp, and time cells show a calendar and clock 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) +- 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) ### Fixed diff --git a/TablePro/Views/Results/DateTimePickerContentView.swift b/TablePro/Views/Results/DateTimePickerContentView.swift index cffd59202..aec76bac1 100644 --- a/TablePro/Views/Results/DateTimePickerContentView.swift +++ b/TablePro/Views/Results/DateTimePickerContentView.swift @@ -2,16 +2,14 @@ // DateTimePickerContentView.swift // TablePro // -// Graphical calendar/clock popover for editing date, datetime, timestamp, +// Native SwiftUI date picker popover for editing date, datetime, timestamp, // and time columns in the data grid. // -import AppKit import SwiftUI struct DateTimePickerContentView: View { - let initialDate: Date - let elements: NSDatePicker.ElementFlags + let components: TemporalComponents let timeZone: TimeZone let onCommit: (Date) -> Void let onDismiss: () -> Void @@ -20,13 +18,12 @@ struct DateTimePickerContentView: View { init( initialDate: Date, - elements: NSDatePicker.ElementFlags, + components: TemporalComponents, timeZone: TimeZone, onCommit: @escaping (Date) -> Void, onDismiss: @escaping () -> Void ) { - self.initialDate = initialDate - self.elements = elements + self.components = components self.timeZone = timeZone self.onCommit = onCommit self.onDismiss = onDismiss @@ -35,8 +32,10 @@ struct DateTimePickerContentView: View { var body: some View { VStack(spacing: 0) { - GraphicalDatePicker(date: $date, elements: elements, timeZone: timeZone) - .fixedSize() + picker + .labelsHidden() + .environment(\.calendar, Calendar(identifier: .gregorian)) + .environment(\.timeZone, timeZone) .padding(12) Divider() @@ -56,49 +55,19 @@ struct DateTimePickerContentView: View { } .fixedSize() } -} - -private struct GraphicalDatePicker: NSViewRepresentable { - @Binding var date: Date - let elements: NSDatePicker.ElementFlags - let timeZone: TimeZone - - func makeNSView(context: Context) -> NSDatePicker { - let picker = NSDatePicker() - picker.datePickerStyle = .clockAndCalendar - picker.datePickerMode = .single - picker.datePickerElements = elements - picker.calendar = Calendar(identifier: .gregorian) - picker.timeZone = timeZone - picker.target = context.coordinator - picker.action = #selector(Coordinator.dateChanged(_:)) - picker.dateValue = date - picker.sizeToFit() - return picker - } - - func updateNSView(_ picker: NSDatePicker, context: Context) { - context.coordinator.date = $date - picker.timeZone = timeZone - if picker.dateValue != date { - picker.dateValue = date - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(date: $date) - } - - @MainActor - final class Coordinator: NSObject { - var date: Binding - - init(date: Binding) { - self.date = date - } - @objc func dateChanged(_ sender: NSDatePicker) { - date.wrappedValue = sender.dateValue + @ViewBuilder + private var picker: some View { + switch components { + case .dateOnly: + DatePicker("", selection: $date, displayedComponents: [.date]) + .datePickerStyle(.graphical) + case .timeOnly: + DatePicker("", selection: $date, displayedComponents: [.hourMinuteAndSecond]) + .datePickerStyle(.stepperField) + case .dateAndTime: + DatePicker("", selection: $date, displayedComponents: [.date, .hourMinuteAndSecond]) + .datePickerStyle(.graphical) } } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift index 585f600e5..5a7ff8605 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -185,7 +185,7 @@ extension TableViewCoordinator { let parsed = DateEditingService.parse(cellValue(at: row, column: columnIndex)) let initialDate = parsed?.date ?? Date() let timeZone = parsed?.timeZone ?? .gmt - let elements = datePickerElements(for: columnType) + let components = DateEditingService.components(for: columnType) let cellRect = tableView.rect(ofRow: row).intersection(tableView.rect(ofColumn: column)) PopoverPresenter.show( @@ -194,7 +194,7 @@ extension TableViewCoordinator { ) { [weak self] dismiss in DateTimePickerContentView( initialDate: initialDate, - elements: elements, + components: components, timeZone: timeZone, onCommit: { picked in let newValue = parsed.map { DateEditingService.string(from: picked, like: $0) } @@ -206,14 +206,6 @@ extension TableViewCoordinator { } } - private func datePickerElements(for columnType: ColumnType) -> NSDatePicker.ElementFlags { - switch DateEditingService.components(for: columnType) { - case .dateOnly: return .yearMonthDay - case .timeOnly: return .hourMinuteSecond - case .dateAndTime: return [.yearMonthDay, .hourMinuteSecond] - } - } - func showEnumPopover(tableView: NSTableView, row: Int, column: Int, columnIndex: Int) { guard tableView.view(atColumn: column, row: row, makeIfNecessary: false) != nil else { return } let tableRows = tableRowsProvider() diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 335f10b1c..b1d3f2803 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -103,7 +103,7 @@ To edit a cell: ### Date/Time Picker -Date, datetime, timestamp, and time columns have a native calendar and clock picker. Double-click the cell to edit the value as text, or click the chevron button to open the picker. The picker adapts to the column type: `DATE` shows year, month, and day; `DATETIME` and `TIMESTAMP` add hour, minute, and second; `TIME` shows time only. Picking a value keeps the cell's existing format, including fractional seconds and any timezone offset; an empty or unparseable cell starts at the current date and time. To set the current timestamp or `NULL`, right-click the cell and use Set Value. +Date, datetime, timestamp, and time columns have a native date picker. Double-click the cell to edit the value as text, or click the chevron button to open the picker. The picker adapts to the column type: `DATE` shows year, month, and day; `DATETIME` and `TIMESTAMP` add hour, minute, and second; `TIME` shows time only. Picking a value keeps the cell's existing format, including fractional seconds and any timezone offset; an empty or unparseable cell starts at the current date and time. To set the current timestamp or `NULL`, right-click the cell and use Set Value. {/* Screenshot: Date picker for editing date columns */} From d14a2840607424293bee1afa6da83e949c0fad46 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 16:19:29 +0700 Subject: [PATCH 5/7] fix(datagrid): use .hourAndMinute on macOS and preserve original seconds in date picker (#1405) --- TablePro/Core/Services/Formatting/DateEditingService.swift | 3 ++- TablePro/Views/Results/DateTimePickerContentView.swift | 4 ++-- TableProTests/Core/Services/DateEditingServiceTests.swift | 7 +++++++ docs/features/data-grid.mdx | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Services/Formatting/DateEditingService.swift b/TablePro/Core/Services/Formatting/DateEditingService.swift index 469b6642c..ca588cad6 100644 --- a/TablePro/Core/Services/Formatting/DateEditingService.swift +++ b/TablePro/Core/Services/Formatting/DateEditingService.swift @@ -92,7 +92,8 @@ enum DateEditingService { static func string(from date: Date, like parsed: ParsedTemporalValue) -> String { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = parsed.timeZone - let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + var components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + components.second = calendar.component(.second, from: parsed.date) let layout = parsed.layout let datePart = dateString(from: components) diff --git a/TablePro/Views/Results/DateTimePickerContentView.swift b/TablePro/Views/Results/DateTimePickerContentView.swift index aec76bac1..c3ef8d2bb 100644 --- a/TablePro/Views/Results/DateTimePickerContentView.swift +++ b/TablePro/Views/Results/DateTimePickerContentView.swift @@ -63,10 +63,10 @@ struct DateTimePickerContentView: View { DatePicker("", selection: $date, displayedComponents: [.date]) .datePickerStyle(.graphical) case .timeOnly: - DatePicker("", selection: $date, displayedComponents: [.hourMinuteAndSecond]) + DatePicker("", selection: $date, displayedComponents: [.hourAndMinute]) .datePickerStyle(.stepperField) case .dateAndTime: - DatePicker("", selection: $date, displayedComponents: [.date, .hourMinuteAndSecond]) + DatePicker("", selection: $date, displayedComponents: [.date, .hourAndMinute]) .datePickerStyle(.graphical) } } diff --git a/TableProTests/Core/Services/DateEditingServiceTests.swift b/TableProTests/Core/Services/DateEditingServiceTests.swift index ba4140255..f9ef2f2f2 100644 --- a/TableProTests/Core/Services/DateEditingServiceTests.swift +++ b/TableProTests/Core/Services/DateEditingServiceTests.swift @@ -84,6 +84,13 @@ struct DateEditingServiceTests { #expect(DateEditingService.string(from: nextDay, like: parsed) == "2024-03-16 09:30:00.123456") } + @Test("editing to the minute keeps the original seconds") + func editKeepsSeconds() throws { + let original = try #require(DateEditingService.parse("2024-03-15 09:30:45")) + let editedToMinute = try #require(DateEditingService.parse("2024-03-15 09:35:00")) + #expect(DateEditingService.string(from: editedToMinute.date, like: original) == "2024-03-15 09:35:45") + } + @Test("null, empty, and whitespace parse to nil") func nullParsesToNil() { #expect(DateEditingService.parse(nil) == nil) diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index b1d3f2803..e71580794 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -103,7 +103,7 @@ To edit a cell: ### Date/Time Picker -Date, datetime, timestamp, and time columns have a native date picker. Double-click the cell to edit the value as text, or click the chevron button to open the picker. The picker adapts to the column type: `DATE` shows year, month, and day; `DATETIME` and `TIMESTAMP` add hour, minute, and second; `TIME` shows time only. Picking a value keeps the cell's existing format, including fractional seconds and any timezone offset; an empty or unparseable cell starts at the current date and time. To set the current timestamp or `NULL`, right-click the cell and use Set Value. +Date, datetime, timestamp, and time columns have a native date picker. Double-click the cell to edit the value as text, or click the chevron button to open the picker. The picker adapts to the column type: `DATE` shows a calendar; `DATETIME` and `TIMESTAMP` add hour and minute; `TIME` shows hour and minute. The picker edits to the minute and keeps the value's existing seconds, fractional seconds, format, and timezone offset, so double-click the cell to type an exact second. An empty or unparseable cell starts at the current date and time. To set the current timestamp or `NULL`, right-click the cell and use Set Value. {/* Screenshot: Date picker for editing date columns */} From e50d89f184c7c96b36c522d70e392080885de1e9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 16:27:38 +0700 Subject: [PATCH 6/7] feat(datagrid): custom SwiftUI calendar picker with seconds for date cells (#1405) --- .../Formatting/DateEditingService.swift | 3 +- .../Results/DateTimePickerContentView.swift | 229 ++++++++++++++++-- .../Services/DateEditingServiceTests.swift | 8 +- docs/features/data-grid.mdx | 2 +- 4 files changed, 214 insertions(+), 28 deletions(-) diff --git a/TablePro/Core/Services/Formatting/DateEditingService.swift b/TablePro/Core/Services/Formatting/DateEditingService.swift index ca588cad6..469b6642c 100644 --- a/TablePro/Core/Services/Formatting/DateEditingService.swift +++ b/TablePro/Core/Services/Formatting/DateEditingService.swift @@ -92,8 +92,7 @@ enum DateEditingService { static func string(from date: Date, like parsed: ParsedTemporalValue) -> String { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = parsed.timeZone - var components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) - components.second = calendar.component(.second, from: parsed.date) + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) let layout = parsed.layout let datePart = dateString(from: components) diff --git a/TablePro/Views/Results/DateTimePickerContentView.swift b/TablePro/Views/Results/DateTimePickerContentView.swift index c3ef8d2bb..51eeeb2ba 100644 --- a/TablePro/Views/Results/DateTimePickerContentView.swift +++ b/TablePro/Views/Results/DateTimePickerContentView.swift @@ -2,18 +2,19 @@ // DateTimePickerContentView.swift // TablePro // -// Native SwiftUI date picker popover for editing date, datetime, timestamp, -// and time columns in the data grid. +// Custom SwiftUI date picker popover for editing date, datetime, timestamp, +// and time columns in the data grid. macOS has no native modern calendar grid, +// so the month view and time field are built here. // import SwiftUI struct DateTimePickerContentView: View { let components: TemporalComponents - let timeZone: TimeZone let onCommit: (Date) -> Void let onDismiss: () -> Void + private let calendar: Calendar @State private var date: Date init( @@ -24,19 +25,25 @@ struct DateTimePickerContentView: View { onDismiss: @escaping () -> Void ) { self.components = components - self.timeZone = timeZone self.onCommit = onCommit self.onDismiss = onDismiss + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + self.calendar = calendar self._date = State(initialValue: initialDate) } var body: some View { VStack(spacing: 0) { - picker - .labelsHidden() - .environment(\.calendar, Calendar(identifier: .gregorian)) - .environment(\.timeZone, timeZone) - .padding(12) + VStack(spacing: 12) { + if components != .timeOnly { + CalendarMonthView(date: $date, calendar: calendar) + } + if components != .dateOnly { + TimeFieldView(date: $date, calendar: calendar) + } + } + .padding(12) Divider() @@ -53,21 +60,201 @@ struct DateTimePickerContentView: View { .padding(.horizontal, 12) .padding(.vertical, 8) } - .fixedSize() + .frame(width: components == .timeOnly ? 200 : 252) + } +} + +private struct CalendarMonthView: View { + @Binding var date: Date + let calendar: Calendar + + @State private var visibleMonth: Date + + private let cellSize: CGFloat = 30 + private let columns = Array(repeating: GridItem(.fixed(30), spacing: 3), count: 7) + + init(date: Binding, calendar: Calendar) { + self._date = date + self.calendar = calendar + self._visibleMonth = State(initialValue: date.wrappedValue) + } + + var body: some View { + VStack(spacing: 6) { + header + LazyVGrid(columns: columns, spacing: 3) { + ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in + Text(symbol) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: cellSize, height: 18) + } + ForEach(dayCells) { cell in + dayCell(cell) + } + } + } + } + + private var header: some View { + HStack { + Button { shiftMonth(-1) } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.borderless) + + Spacer() + + Text(monthTitle) + .font(.headline) + + Spacer() + + Button { shiftMonth(1) } label: { + Image(systemName: "chevron.right") + } + .buttonStyle(.borderless) + } } @ViewBuilder - private var picker: some View { - switch components { - case .dateOnly: - DatePicker("", selection: $date, displayedComponents: [.date]) - .datePickerStyle(.graphical) - case .timeOnly: - DatePicker("", selection: $date, displayedComponents: [.hourAndMinute]) - .datePickerStyle(.stepperField) - case .dateAndTime: - DatePicker("", selection: $date, displayedComponents: [.date, .hourAndMinute]) - .datePickerStyle(.graphical) + private func dayCell(_ cell: DayCell) -> some View { + if let day = cell.date { + let isSelected = calendar.isDate(day, inSameDayAs: date) + let isToday = calendar.isDateInToday(day) + Button { + select(day) + } label: { + Text("\(calendar.component(.day, from: day))") + .font(.callout) + .frame(width: cellSize, height: cellSize) + .background { + if isSelected { + Circle().fill(Color.accentColor) + } else if isToday { + Circle().strokeBorder(Color.accentColor, lineWidth: 1) + } + } + .foregroundStyle(dayColor(isSelected: isSelected, isToday: isToday)) + } + .buttonStyle(.plain) + } else { + Color.clear.frame(width: cellSize, height: cellSize) + } + } + + private func dayColor(isSelected: Bool, isToday: Bool) -> Color { + if isSelected { return .white } + if isToday { return .accentColor } + return .primary + } + + private var dayCells: [DayCell] { + guard let monthInterval = calendar.dateInterval(of: .month, for: visibleMonth), + let dayCount = calendar.range(of: .day, in: .month, for: visibleMonth)?.count else { + return [] + } + let firstWeekday = calendar.component(.weekday, from: monthInterval.start) + let leadingBlanks = (firstWeekday - calendar.firstWeekday + 7) % 7 + + var cells: [DayCell] = (0..) -> some View { + let binding = Binding( + get: { calendar.component(unit, from: date) }, + set: { set(unit, to: min(range.upperBound, max(range.lowerBound, $0))) } + ) + return HStack(spacing: 1) { + TextField("", value: binding, formatter: Self.formatter) + .frame(width: 26) + .multilineTextAlignment(.center) + .textFieldStyle(.roundedBorder) + Stepper("", value: binding, in: range) + .labelsHidden() + } + } + + private func set(_ unit: Calendar.Component, to value: Int) { + var dateComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + switch unit { + case .hour: dateComponents.hour = value + case .minute: dateComponents.minute = value + case .second: dateComponents.second = value + default: break + } + if let newDate = calendar.date(from: dateComponents) { + date = newDate } } } diff --git a/TableProTests/Core/Services/DateEditingServiceTests.swift b/TableProTests/Core/Services/DateEditingServiceTests.swift index f9ef2f2f2..7a306205a 100644 --- a/TableProTests/Core/Services/DateEditingServiceTests.swift +++ b/TableProTests/Core/Services/DateEditingServiceTests.swift @@ -84,11 +84,11 @@ struct DateEditingServiceTests { #expect(DateEditingService.string(from: nextDay, like: parsed) == "2024-03-16 09:30:00.123456") } - @Test("editing to the minute keeps the original seconds") - func editKeepsSeconds() throws { + @Test("editing the time updates hour, minute, and second") + func editUpdatesTime() throws { let original = try #require(DateEditingService.parse("2024-03-15 09:30:45")) - let editedToMinute = try #require(DateEditingService.parse("2024-03-15 09:35:00")) - #expect(DateEditingService.string(from: editedToMinute.date, like: original) == "2024-03-15 09:35:45") + let edited = try #require(DateEditingService.parse("2024-03-15 10:15:05")) + #expect(DateEditingService.string(from: edited.date, like: original) == "2024-03-15 10:15:05") } @Test("null, empty, and whitespace parse to nil") diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index e71580794..9a3cdb966 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -103,7 +103,7 @@ To edit a cell: ### Date/Time Picker -Date, datetime, timestamp, and time columns have a native date picker. Double-click the cell to edit the value as text, or click the chevron button to open the picker. The picker adapts to the column type: `DATE` shows a calendar; `DATETIME` and `TIMESTAMP` add hour and minute; `TIME` shows hour and minute. The picker edits to the minute and keeps the value's existing seconds, fractional seconds, format, and timezone offset, so double-click the cell to type an exact second. An empty or unparseable cell starts at the current date and time. To set the current timestamp or `NULL`, right-click the cell and use Set Value. +Date, datetime, timestamp, and time columns have a date picker. Double-click the cell to edit the value as text, or click the chevron button to open the picker, which shows a calendar for the date and hour, minute, and second fields for the time. `DATE` shows the calendar only; `TIME` shows the time fields only; `DATETIME` and `TIMESTAMP` show both. The picker keeps the value's existing format, fractional seconds, and timezone offset. An empty or unparseable cell starts at the current date and time. To set the current timestamp or `NULL`, right-click the cell and use Set Value. {/* Screenshot: Date picker for editing date columns */} From 182af826f82217a566c1095de6392b3bc3bc8e77 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 17:09:06 +0700 Subject: [PATCH 7/7] refactor(datagrid): extract testable CalendarMonth and add accessibility labels to date picker (#1405) --- TablePro/Core/Services/CalendarMonth.swift | 40 ++++++++++ .../Results/DateTimePickerContentView.swift | 76 +++++++----------- .../Core/Services/CalendarMonthTests.swift | 77 +++++++++++++++++++ 3 files changed, 146 insertions(+), 47 deletions(-) create mode 100644 TablePro/Core/Services/CalendarMonth.swift create mode 100644 TableProTests/Core/Services/CalendarMonthTests.swift diff --git a/TablePro/Core/Services/CalendarMonth.swift b/TablePro/Core/Services/CalendarMonth.swift new file mode 100644 index 000000000..2b4fc7f88 --- /dev/null +++ b/TablePro/Core/Services/CalendarMonth.swift @@ -0,0 +1,40 @@ +// +// CalendarMonth.swift +// TablePro +// +// Month grid layout for the data grid's date picker: leading blank cells, +// the days of the month, and weekday header symbols ordered by the calendar's +// first weekday. +// + +import Foundation + +struct CalendarMonth: Equatable { + let leadingBlanks: Int + let dayCount: Int + let days: [Date?] + let weekdaySymbols: [String] + + init?(containing date: Date, calendar: Calendar) { + guard let monthInterval = calendar.dateInterval(of: .month, for: date), + let dayCount = calendar.range(of: .day, in: .month, for: date)?.count else { + return nil + } + + let firstWeekday = calendar.component(.weekday, from: monthInterval.start) + let leadingBlanks = (firstWeekday - calendar.firstWeekday + 7) % 7 + + var days: [Date?] = Array(repeating: nil, count: leadingBlanks) + for offset in 0.., calendar: Calendar) { self._date = date self.calendar = calendar self._visibleMonth = State(initialValue: date.wrappedValue) + self.monthTitleFormatter = Self.makeFormatter(calendar: calendar) { $0.dateFormat = "MMMM yyyy" } + self.dayLabelFormatter = Self.makeFormatter(calendar: calendar) { + $0.dateStyle = .long + $0.timeStyle = .none + } } var body: some View { VStack(spacing: 6) { header - LazyVGrid(columns: columns, spacing: 3) { - ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in - Text(symbol) - .font(.caption2) - .foregroundStyle(.secondary) - .frame(width: cellSize, height: 18) - } - ForEach(dayCells) { cell in - dayCell(cell) + if let month = CalendarMonth(containing: visibleMonth, calendar: calendar) { + LazyVGrid(columns: columns, spacing: 3) { + ForEach(Array(month.weekdaySymbols.enumerated()), id: \.offset) { _, symbol in + Text(symbol) + .font(.caption2) + .foregroundStyle(.secondary) + .frame(width: cellSize, height: 18) + } + ForEach(Array(month.days.enumerated()), id: \.offset) { _, day in + dayCell(day) + } } } } @@ -105,7 +114,7 @@ private struct CalendarMonthView: View { Spacer() - Text(monthTitle) + Text(monthTitleFormatter.string(from: visibleMonth)) .font(.headline) Spacer() @@ -118,8 +127,8 @@ private struct CalendarMonthView: View { } @ViewBuilder - private func dayCell(_ cell: DayCell) -> some View { - if let day = cell.date { + private func dayCell(_ day: Date?) -> some View { + if let day { let isSelected = calendar.isDate(day, inSameDayAs: date) let isToday = calendar.isDateInToday(day) Button { @@ -138,6 +147,7 @@ private struct CalendarMonthView: View { .foregroundStyle(dayColor(isSelected: isSelected, isToday: isToday)) } .buttonStyle(.plain) + .accessibilityLabel(dayLabelFormatter.string(from: day)) } else { Color.clear.frame(width: cellSize, height: cellSize) } @@ -149,38 +159,6 @@ private struct CalendarMonthView: View { return .primary } - private var dayCells: [DayCell] { - guard let monthInterval = calendar.dateInterval(of: .month, for: visibleMonth), - let dayCount = calendar.range(of: .day, in: .month, for: visibleMonth)?.count else { - return [] - } - let firstWeekday = calendar.component(.weekday, from: monthInterval.start) - let leadingBlanks = (firstWeekday - calendar.firstWeekday + 7) % 7 - - var cells: [DayCell] = (0.. Void) -> DateFormatter { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.locale = .current + configure(formatter) + return formatter } } diff --git a/TableProTests/Core/Services/CalendarMonthTests.swift b/TableProTests/Core/Services/CalendarMonthTests.swift new file mode 100644 index 000000000..334285ec6 --- /dev/null +++ b/TableProTests/Core/Services/CalendarMonthTests.swift @@ -0,0 +1,77 @@ +// +// CalendarMonthTests.swift +// TableProTests +// +// Tests for the date picker's month grid layout: leading blanks, day count, +// and weekday symbol ordering across first-weekday settings. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Calendar Month") +struct CalendarMonthTests { + private func calendar(firstWeekday: Int) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.firstWeekday = firstWeekday + return calendar + } + + private func date(_ year: Int, _ month: Int, _ day: Int, in calendar: Calendar) throws -> Date { + try #require(calendar.date(from: DateComponents(year: year, month: month, day: day))) + } + + @Test("March 2024 starts on Friday: 5 leading blanks with Sunday first") + func leadingBlanksSundayFirst() throws { + let sundayFirst = calendar(firstWeekday: 1) + let month = try #require(CalendarMonth(containing: date(2_024, 3, 1, in: sundayFirst), calendar: sundayFirst)) + #expect(month.leadingBlanks == 5) + #expect(month.dayCount == 31) + } + + @Test("March 2024 has 4 leading blanks with Monday first") + func leadingBlanksMondayFirst() throws { + let mondayFirst = calendar(firstWeekday: 2) + let month = try #require(CalendarMonth(containing: date(2_024, 3, 1, in: mondayFirst), calendar: mondayFirst)) + #expect(month.leadingBlanks == 4) + } + + @Test("February in a leap year has 29 days") + func februaryLeapYear() throws { + let gregorian = calendar(firstWeekday: 1) + let month = try #require(CalendarMonth(containing: date(2_024, 2, 10, in: gregorian), calendar: gregorian)) + #expect(month.dayCount == 29) + } + + @Test("February in a non-leap year has 28 days") + func februaryNonLeapYear() throws { + let gregorian = calendar(firstWeekday: 1) + let month = try #require(CalendarMonth(containing: date(2_023, 2, 10, in: gregorian), calendar: gregorian)) + #expect(month.dayCount == 28) + } + + @Test("days array is leading blanks followed by each day of the month") + func daysArrayShape() throws { + let gregorian = calendar(firstWeekday: 1) + let month = try #require(CalendarMonth(containing: date(2_024, 3, 1, in: gregorian), calendar: gregorian)) + #expect(month.days.count == month.leadingBlanks + month.dayCount) + #expect(month.days.prefix(month.leadingBlanks).allSatisfy { $0 == nil }) + let firstDay = try #require(month.days[month.leadingBlanks]) + #expect(gregorian.component(.day, from: firstDay) == 1) + } + + @Test("weekday symbols rotate to the calendar's first weekday") + func weekdaySymbolOrdering() throws { + let sundayFirst = calendar(firstWeekday: 1) + let mondayFirst = calendar(firstWeekday: 2) + let sunday = try #require(CalendarMonth(containing: date(2_024, 3, 1, in: sundayFirst), calendar: sundayFirst)) + let monday = try #require(CalendarMonth(containing: date(2_024, 3, 1, in: mondayFirst), calendar: mondayFirst)) + #expect(sunday.weekdaySymbols.count == 7) + #expect(sunday.weekdaySymbols.first == sundayFirst.veryShortWeekdaySymbols[0]) + #expect(monday.weekdaySymbols.first == mondayFirst.veryShortWeekdaySymbols[1]) + } +}