diff --git a/CHANGELOG.md b/CHANGELOG.md index 664619fb3..af3af0750 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 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) - 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) - Click the page indicator in the pagination bar to jump to a specific page. (#1364) - 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) 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 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.. 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) + } + + 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) + } + + 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..d44c56999 100644 --- a/TablePro/Views/Results/Cells/DataGridCellKind.swift +++ b/TablePro/Views/Results/Cells/DataGridCellKind.swift @@ -12,4 +12,14 @@ enum DataGridCellKind: Equatable { case boolean 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/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..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: - 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: + } 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: - 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/DateTimePickerContentView.swift b/TablePro/Views/Results/DateTimePickerContentView.swift new file mode 100644 index 000000000..ebea4a010 --- /dev/null +++ b/TablePro/Views/Results/DateTimePickerContentView.swift @@ -0,0 +1,242 @@ +// +// DateTimePickerContentView.swift +// TablePro +// +// 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 onCommit: (Date) -> Void + let onDismiss: () -> Void + + private let calendar: Calendar + @State private var date: Date + + init( + initialDate: Date, + components: TemporalComponents, + timeZone: TimeZone, + onCommit: @escaping (Date) -> Void, + onDismiss: @escaping () -> Void + ) { + self.components = components + 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) { + VStack(spacing: 12) { + if components != .timeOnly { + CalendarMonthView(date: $date, calendar: calendar) + } + if components != .dateOnly { + TimeFieldView(date: $date, calendar: calendar) + } + } + .padding(12) + + Divider() + + HStack { + Spacer() + Button("Cancel") { onDismiss() } + .keyboardShortcut(.cancelAction) + Button("OK") { + onCommit(date) + onDismiss() + } + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + .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) + private let monthTitleFormatter: DateFormatter + private let dayLabelFormatter: DateFormatter + + init(date: Binding, 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 + 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) + } + } + } + } + } + + private var header: some View { + HStack { + Button { shiftMonth(-1) } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.borderless) + + Spacer() + + Text(monthTitleFormatter.string(from: visibleMonth)) + .font(.headline) + + Spacer() + + Button { shiftMonth(1) } label: { + Image(systemName: "chevron.right") + } + .buttonStyle(.borderless) + } + } + + @ViewBuilder + private func dayCell(_ day: Date?) -> some View { + if let day { + 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) + .accessibilityLabel(dayLabelFormatter.string(from: day)) + } 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 func shiftMonth(_ delta: Int) { + if let month = calendar.date(byAdding: .month, value: delta, to: visibleMonth) { + visibleMonth = month + } + } + + private func select(_ day: Date) { + var dayComponents = calendar.dateComponents([.year, .month, .day], from: day) + let time = calendar.dateComponents([.hour, .minute, .second], from: date) + dayComponents.hour = time.hour + dayComponents.minute = time.minute + dayComponents.second = time.second + if let newDate = calendar.date(from: dayComponents) { + date = newDate + } + } + + private static func makeFormatter(calendar: Calendar, configure: (DateFormatter) -> Void) -> DateFormatter { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.locale = .current + configure(formatter) + return formatter + } +} + +private struct TimeFieldView: View { + @Binding var date: Date + let calendar: Calendar + + private static let formatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.minimumIntegerDigits = 2 + formatter.maximumFractionDigits = 0 + formatter.allowsFloats = false + return formatter + }() + + var body: some View { + HStack(spacing: 6) { + field(for: .hour, range: 0...23) + separator + field(for: .minute, range: 0...59) + separator + field(for: .second, range: 0...59) + } + } + + private var separator: some View { + Text(":").foregroundStyle(.secondary) + } + + private func field(for unit: Calendar.Component, range: ClosedRange) -> 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/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..5a7ff8605 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Popovers.swift @@ -176,6 +176,36 @@ 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 components = DateEditingService.components(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, + components: components, + 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 + ) + } + } + 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/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]) + } +} 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..7a306205a --- /dev/null +++ b/TableProTests/Core/Services/DateEditingServiceTests.swift @@ -0,0 +1,147 @@ +// +// 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("editing the time updates hour, minute, and second") + func editUpdatesTime() throws { + let original = try #require(DateEditingService.parse("2024-03-15 09:30: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") + 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") + } + + @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) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index a3913b0d1..a669d81d4 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 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 */}