From af84470547943012f18719701cfe30ede5c0fa44 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Mar 2026 23:47:42 +0700 Subject: [PATCH 1/5] feat: redesign right sidebar with compact layout and type-aware editors --- CHANGELOG.md | 4 + TablePro/Core/Services/ColumnType.swift | 21 ++ TablePro/Models/MultiRowEditState.swift | 12 +- .../RightSidebar/EditableFieldView.swift | 331 ++++++++++++------ .../Views/RightSidebar/RightSidebarView.swift | 70 ++-- 5 files changed, 301 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd63351..0ec431e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Toolbar briefly showing "MySQL" and missing version (e.g., "MongoDB" instead of "MongoDB 8.2.5") when opening a new tab - Keyboard shortcuts not working (beep sound) after connecting from welcome screen until a second tab is opened +### Changed + +- Redesigned right sidebar detail pane with compact field layout and type-aware editors + ## [0.10.0] - 2026-03-01 ### Added diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index fda2ce8a..3c6f7ece 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -288,6 +288,27 @@ enum ColumnType: Equatable { } } + var isBooleanType: Bool { + switch self { + case .boolean: return true + default: return false + } + } + + /// Compact lowercase badge label for sidebar + var badgeLabel: String { + switch self { + case .boolean: return "bool" + case .json: return "json" + case .date, .timestamp, .datetime: return "date" + case .enumType: return "enum" + case .set: return "set" + case .integer, .decimal: return "number" + case .blob: return "binary" + case .text: return "string" + } + } + /// The allowed enum/set values, if known var enumValues: [String]? { switch self { diff --git a/TablePro/Models/MultiRowEditState.swift b/TablePro/Models/MultiRowEditState.swift index 597ab136..e4c2dd98 100644 --- a/TablePro/Models/MultiRowEditState.swift +++ b/TablePro/Models/MultiRowEditState.swift @@ -14,7 +14,8 @@ struct FieldEditState { let columnIndex: Int let columnName: String let columnType: String - let isLongText: Bool // NEW: Whether to use multi-line editor + let columnTypeEnum: ColumnType + let isLongText: Bool /// Original values from all selected rows (nil if multiple different values) let originalValue: String? @@ -118,6 +119,7 @@ class MultiRowEditState: ObservableObject { columnIndex: colIndex, columnName: columnName, columnType: columnType, + columnTypeEnum: columnTypeEnum, isLongText: isLongText, originalValue: originalValue, hasMultipleValues: hasMultipleValues, @@ -162,6 +164,14 @@ class MultiRowEditState: ObservableObject { fields[index].isPendingDefault = false } + /// Set a field to empty string + func setFieldToEmpty(at index: Int) { + guard index < fields.count else { return } + fields[index].pendingValue = "" + fields[index].isPendingNull = false + fields[index].isPendingDefault = false + } + /// Clear all pending edits func clearEdits() { for i in 0.. Void let onSetDefault: () -> Void + let onSetEmpty: () -> Void let onSetFunction: (String) -> Void + let onUpdateValue: (String) -> Void @FocusState private var isFocused: Bool - private var displayValue: String { - if isPendingNull { - return "NULL" - } else if isPendingDefault { - return "DEFAULT" - } else { - return value - } - } - private var placeholderText: String { if hasMultipleValues { - return "Multiple values" - } else if isPendingNull { - return "NULL" - } else if isPendingDefault { - return "DEFAULT" + return String(localized: "Multiple values") } else if let original = originalValue { return original } else { @@ -51,149 +41,264 @@ struct EditableFieldView: View { } var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { + // Line 1: modified indicator + field name + type badge HStack(spacing: 4) { - Text(columnName) - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.secondary) - - Text(columnType) - .font(.system(size: DesignConstants.FontSize.tiny)) - .foregroundStyle(.tertiary) - if isModified { Circle() .fill(Color.accentColor) .frame(width: 6, height: 6) } + + Text(columnName) + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .lineLimit(1) + + Spacer() + + Text(columnTypeEnum.badgeLabel) + .font(.system(size: DesignConstants.FontSize.tiny)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color(NSColor.quaternaryLabelColor).opacity(0.3)) + .clipShape(Capsule()) } + // Line 2: type-aware editor + menu button HStack(spacing: 4) { - if isLongText { - TextEditor(text: $value) - .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) - .disabled(isPendingNull || isPendingDefault) - .focused($isFocused) - .frame(height: 120) - .scrollContentBackground(.hidden) - .padding(4) - .background(Color(NSColor.textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay( - RoundedRectangle(cornerRadius: 5) - .strokeBorder(Color(NSColor.separatorColor).opacity(0.5)) - ) - } else { - TextField(placeholderText, text: $value) - .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.small)) - .disabled(isPendingNull || isPendingDefault) - .focused($isFocused) - } + typeAwareEditor - Menu { - Button("Set NULL") { - onSetNull() - } + fieldMenu + } + } + } - Button("Set DEFAULT") { - onSetDefault() - } + // MARK: - Type-Aware Editor - Divider() - - Menu("SQL Functions") { - Button("NOW()") { - onSetFunction("NOW()") - } - Button("CURRENT_TIMESTAMP()") { - onSetFunction("CURRENT_TIMESTAMP()") - } - Button("CURDATE()") { - onSetFunction("CURDATE()") - } - Button("CURTIME()") { - onSetFunction("CURTIME()") - } - Button("UTC_TIMESTAMP()") { - onSetFunction("UTC_TIMESTAMP()") - } - } + @ViewBuilder + private var typeAwareEditor: some View { + if isPendingNull { + specialValueLabel("NULL") + } else if isPendingDefault { + specialValueLabel("DEFAULT") + } else if columnTypeEnum.isBooleanType { + booleanPicker + } else if columnTypeEnum.isEnumType, let values = columnTypeEnum.enumValues, !values.isEmpty { + enumPicker(values: values) + } else if isLongText || columnTypeEnum.isJsonType { + multiLineEditor + } else { + singleLineEditor + } + } + + private func specialValueLabel(_ label: String) -> some View { + Text(label) + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .italic() + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + + private var booleanPicker: some View { + Picker("", selection: Binding( + get: { normalizeBooleanValue(value) }, + set: { onUpdateValue($0) } + )) { + Text("true").tag("1") + Text("false").tag("0") + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func enumPicker(values: [String]) -> some View { + Picker("", selection: Binding( + get: { value }, + set: { onUpdateValue($0) } + )) { + ForEach(values, id: \.self) { val in + Text(val).tag(val) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var multiLineEditor: some View { + TextEditor(text: $value) + .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) + .focused($isFocused) + .frame(height: 80) + .scrollContentBackground(.hidden) + .padding(4) + .background(Color(NSColor.textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Color(NSColor.separatorColor).opacity(0.5)) + ) + } + + private var singleLineEditor: some View { + TextField(placeholderText, text: $value) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.small)) + .focused($isFocused) + } + + // MARK: - Field Menu + + private var fieldMenu: some View { + Menu { + Button("Set NULL") { + onSetNull() + } + + Button("Set DEFAULT") { + onSetDefault() + } + + Button("Set EMPTY") { + onSetEmpty() + } - if isPendingNull || isPendingDefault { - Divider() - Button("Clear") { - value = originalValue ?? "" - } + Divider() + + if columnTypeEnum.isJsonType { + Button("Pretty Print") { + if let formatted = prettyPrintJson(value) { + onUpdateValue(formatted) } - } label: { - Image(systemName: "ellipsis.circle") - .imageScale(.small) - .foregroundStyle(.secondary) - .frame(width: 20, height: 20) } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .fixedSize() - .help("Set special value") } + + Button("Copy Value") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + + Divider() + + Menu("SQL Functions") { + Button("NOW()") { onSetFunction("NOW()") } + Button("CURRENT_TIMESTAMP()") { onSetFunction("CURRENT_TIMESTAMP()") } + Button("CURDATE()") { onSetFunction("CURDATE()") } + Button("CURTIME()") { onSetFunction("CURTIME()") } + Button("UTC_TIMESTAMP()") { onSetFunction("UTC_TIMESTAMP()") } + } + + if isPendingNull || isPendingDefault { + Divider() + Button("Clear") { + onUpdateValue(originalValue ?? "") + } + } + } label: { + Image(systemName: "chevron.down") + .imageScale(.small) + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + + // MARK: - Helpers + + private func normalizeBooleanValue(_ val: String) -> String { + let lower = val.lowercased() + if lower == "true" || lower == "1" || lower == "t" || lower == "yes" { + return "1" } + return "0" + } + + private func prettyPrintJson(_ jsonString: String) -> String? { + guard let data = jsonString.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: data), + let prettyData = try? JSONSerialization.data( + withJSONObject: jsonObject, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ), + let prettyString = String(data: prettyData, encoding: .utf8) else { + return nil + } + return prettyString } } -/// Read-only field view (for readonly mode or deleted rows) +/// Read-only field view with compact layout struct ReadOnlyFieldView: View { let columnName: String let columnType: String - let isLongText: Bool // NEW: Whether to use multi-line display + let columnTypeEnum: ColumnType + let isLongText: Bool let value: String? var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { + // Line 1: field name + type badge HStack(spacing: 4) { Text(columnName) - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.secondary) + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .lineLimit(1) - Text(columnType) + Spacer() + + Text(columnTypeEnum.badgeLabel) .font(.system(size: DesignConstants.FontSize.tiny)) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color(NSColor.quaternaryLabelColor).opacity(0.3)) + .clipShape(Capsule()) } + // Line 2: value display Group { - if isLongText { - if let value = value { + if let value { + if isLongText || columnTypeEnum.isJsonType { Text(value) .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) .textSelection(.enabled) - .frame(maxWidth: .infinity, maxHeight: 120, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: 80, alignment: .topLeading) .clipped() } else { - Text("NULL") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.tertiary) - .italic() - .frame(maxWidth: .infinity, maxHeight: 120, alignment: .topLeading) - } - } else { - if let value = value { Text(value) .font(.system(size: DesignConstants.FontSize.small)) .textSelection(.enabled) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) - } else { - Text("NULL") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.tertiary) - .italic() } + } else { + Text("NULL") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.tertiary) + .italic() + .frame(maxWidth: .infinity, alignment: .leading) } } .padding(.horizontal, 6) .padding(.vertical, 4) .background(Color(NSColor.controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 5)) + .contextMenu { + if let value { + Button("Copy Value") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + } + } } } } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 6e7d1ee6..87133ffe 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -134,20 +134,41 @@ struct RightSidebarView: View { ($0.originalValue?.localizedCaseInsensitiveContains(searchText) ?? false) } - return Form { - Section { - ForEach(filtered, id: \.columnName) { field in - if contentMode == .editRow { - editableFieldRow(field, at: field.columnIndex) - } else { - readonlyFieldRow(field) + return VStack(spacing: 0) { + // Inline search field + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.tertiary) + .font(.system(size: DesignConstants.FontSize.small)) + TextField("Search for field...", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: DesignConstants.FontSize.small)) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + .font(.system(size: DesignConstants.FontSize.small)) } + .buttonStyle(.plain) } - } header: { - VStack(alignment: .leading, spacing: 2) { - if let name = tableName { - Text(name) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + + Divider() + + Form { + Section { + ForEach(filtered, id: \.columnName) { field in + if contentMode == .editRow { + editableFieldRow(field, at: field.columnIndex) + } else { + readonlyFieldRow(field) + } } + } header: { HStack { Text("FIELDS") Spacer() @@ -155,22 +176,21 @@ struct RightSidebarView: View { .foregroundStyle(.secondary) } } - } - if contentMode == .editRow && editState.hasEdits { - Section { - Button(action: onSave) { - Text("Save Changes") - .frame(maxWidth: .infinity) + if contentMode == .editRow && editState.hasEdits { + Section { + Button(action: onSave) { + Text("Save Changes") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut("s", modifiers: .command) } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .keyboardShortcut("s", modifiers: .command) } } + .formStyle(.grouped) } - .formStyle(.grouped) - .searchable(text: $searchText, prompt: "Filter") } @ViewBuilder @@ -178,6 +198,7 @@ struct RightSidebarView: View { EditableFieldView( columnName: field.columnName, columnType: field.columnType, + columnTypeEnum: field.columnTypeEnum, isLongText: field.isLongText, value: Binding( get: { field.pendingValue ?? field.originalValue ?? "" }, @@ -190,7 +211,9 @@ struct RightSidebarView: View { isModified: field.hasEdit, onSetNull: { editState.setFieldToNull(at: index) }, onSetDefault: { editState.setFieldToDefault(at: index) }, - onSetFunction: { editState.setFieldToFunction(at: index, function: $0) } + onSetEmpty: { editState.setFieldToEmpty(at: index) }, + onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, + onUpdateValue: { editState.updateField(at: index, value: $0) } ) } @@ -199,6 +222,7 @@ struct RightSidebarView: View { ReadOnlyFieldView( columnName: field.columnName, columnType: field.columnType, + columnTypeEnum: field.columnTypeEnum, isLongText: field.isLongText, value: field.originalValue ) From 4f0232ee7476299fcde829fb1124e7fb61c64ea2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 1 Mar 2026 23:52:00 +0700 Subject: [PATCH 2/5] fix: replace Form with ScrollView to fix duplicate label layout --- .../Views/RightSidebar/RightSidebarView.swift | 99 +++++++++++-------- 1 file changed, 59 insertions(+), 40 deletions(-) diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 87133ffe..40e098e3 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -159,8 +159,21 @@ struct RightSidebarView: View { Divider() - Form { - Section { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + // Section header + HStack { + Text("FIELDS") + .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Text("\(filtered.count)") + .font(.system(size: DesignConstants.FontSize.tiny)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + ForEach(filtered, id: \.columnName) { field in if contentMode == .editRow { editableFieldRow(field, at: field.columnIndex) @@ -168,17 +181,8 @@ struct RightSidebarView: View { readonlyFieldRow(field) } } - } header: { - HStack { - Text("FIELDS") - Spacer() - Text("\(filtered.count)") - .foregroundStyle(.secondary) - } - } - if contentMode == .editRow && editState.hasEdits { - Section { + if contentMode == .editRow && editState.hasEdits { Button(action: onSave) { Text("Save Changes") .frame(maxWidth: .infinity) @@ -186,46 +190,61 @@ struct RightSidebarView: View { .buttonStyle(.borderedProminent) .controlSize(.large) .keyboardShortcut("s", modifiers: .command) + .padding(.horizontal, 12) + .padding(.vertical, 8) } } } - .formStyle(.grouped) } } @ViewBuilder private func editableFieldRow(_ field: FieldEditState, at index: Int) -> some View { - EditableFieldView( - columnName: field.columnName, - columnType: field.columnType, - columnTypeEnum: field.columnTypeEnum, - isLongText: field.isLongText, - value: Binding( - get: { field.pendingValue ?? field.originalValue ?? "" }, - set: { editState.updateField(at: index, value: $0) } - ), - originalValue: field.originalValue, - hasMultipleValues: field.hasMultipleValues, - isPendingNull: field.isPendingNull, - isPendingDefault: field.isPendingDefault, - isModified: field.hasEdit, - onSetNull: { editState.setFieldToNull(at: index) }, - onSetDefault: { editState.setFieldToDefault(at: index) }, - onSetEmpty: { editState.setFieldToEmpty(at: index) }, - onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, - onUpdateValue: { editState.updateField(at: index, value: $0) } - ) + VStack(spacing: 0) { + EditableFieldView( + columnName: field.columnName, + columnType: field.columnType, + columnTypeEnum: field.columnTypeEnum, + isLongText: field.isLongText, + value: Binding( + get: { field.pendingValue ?? field.originalValue ?? "" }, + set: { editState.updateField(at: index, value: $0) } + ), + originalValue: field.originalValue, + hasMultipleValues: field.hasMultipleValues, + isPendingNull: field.isPendingNull, + isPendingDefault: field.isPendingDefault, + isModified: field.hasEdit, + onSetNull: { editState.setFieldToNull(at: index) }, + onSetDefault: { editState.setFieldToDefault(at: index) }, + onSetEmpty: { editState.setFieldToEmpty(at: index) }, + onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, + onUpdateValue: { editState.updateField(at: index, value: $0) } + ) + .padding(.horizontal, 12) + .padding(.vertical, 6) + + Divider() + .padding(.leading, 12) + } } @ViewBuilder private func readonlyFieldRow(_ field: FieldEditState) -> some View { - ReadOnlyFieldView( - columnName: field.columnName, - columnType: field.columnType, - columnTypeEnum: field.columnTypeEnum, - isLongText: field.isLongText, - value: field.originalValue - ) + VStack(spacing: 0) { + ReadOnlyFieldView( + columnName: field.columnName, + columnType: field.columnType, + columnTypeEnum: field.columnTypeEnum, + isLongText: field.isLongText, + value: field.originalValue + ) + .padding(.horizontal, 12) + .padding(.vertical, 6) + + Divider() + .padding(.leading, 12) + } } } From 5161b4e127531304225fe51caed4dbf9ccc44472 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 2 Mar 2026 00:10:40 +0700 Subject: [PATCH 3/5] fix: native macOS components, hover chevron, and UI polish for sidebar --- TablePro/Models/MultiRowEditState.swift | 7 +- TablePro/Resources/Localizable.xcstrings | 28 ++++ .../RightSidebar/EditableFieldView.swift | 140 ++++++++---------- .../Views/RightSidebar/RightSidebarView.swift | 110 ++++++-------- 4 files changed, 144 insertions(+), 141 deletions(-) diff --git a/TablePro/Models/MultiRowEditState.swift b/TablePro/Models/MultiRowEditState.swift index e4c2dd98..a890dd91 100644 --- a/TablePro/Models/MultiRowEditState.swift +++ b/TablePro/Models/MultiRowEditState.swift @@ -135,7 +135,12 @@ class MultiRowEditState: ObservableObject { /// Update a field's pending value func updateField(at index: Int, value: String?) { guard index < fields.count else { return } - fields[index].pendingValue = value + let original = fields[index].originalValue + if value == original || (original == nil && value == "") { + fields[index].pendingValue = nil + } else { + fields[index].pendingValue = value + } fields[index].isPendingNull = false fields[index].isPendingDefault = false } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 4e176e69..4c0d8c8f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2310,6 +2310,9 @@ } } } + }, + "Copy Value" : { + }, "Copy with Headers" : { "localizations" : { @@ -2795,6 +2798,9 @@ } } } + }, + "DEFAULT" : { + }, "Default Column" : { "localizations" : { @@ -3922,6 +3928,9 @@ } } } + }, + "false" : { + }, "FALSE" : { "extractionState" : "stale", @@ -5225,6 +5234,9 @@ }, "MQL Preview" : { + }, + "Multiple values" : { + }, "Name" : { "localizations" : { @@ -5549,6 +5561,9 @@ } } } + }, + "No matching fields" : { + }, "No Matching Queries" : { "localizations" : { @@ -6304,6 +6319,9 @@ } } } + }, + "Pretty Print" : { + }, "Pretty print (formatted output)" : { "localizations" : { @@ -7234,6 +7252,9 @@ } } } + }, + "Search for field..." : { + }, "Search or type..." : { "localizations" : { @@ -7420,6 +7441,9 @@ } } } + }, + "Set EMPTY" : { + }, "Set NULL" : { "localizations" : { @@ -7432,6 +7456,7 @@ } }, "Set special value" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8569,6 +8594,9 @@ } } } + }, + "true" : { + }, "TRUE" : { "extractionState" : "stale", diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 6591fce0..7bb352f2 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -3,13 +3,13 @@ // TablePro // // Compact, type-aware field editor for right sidebar. -// Two-line layout: field name + type badge, then editor + menu. +// Two-line layout: field name + type badge, then native editor + menu. // import AppKit import SwiftUI -/// Compact editable field view with type-aware editors +/// Compact editable field view using native macOS components struct EditableFieldView: View { let columnName: String let columnType: String @@ -29,6 +29,7 @@ struct EditableFieldView: View { let onUpdateValue: (String) -> Void @FocusState private var isFocused: Bool + @State private var isHovered = false private var placeholderText: String { if hasMultipleValues { @@ -36,12 +37,12 @@ struct EditableFieldView: View { } else if let original = originalValue { return original } else { - return "" + return "NULL" } } var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { // Line 1: modified indicator + field name + type badge HStack(spacing: 4) { if isModified { @@ -51,27 +52,29 @@ struct EditableFieldView: View { } Text(columnName) - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.system(size: DesignConstants.FontSize.small)) .lineLimit(1) Spacer() Text(columnTypeEnum.badgeLabel) - .font(.system(size: DesignConstants.FontSize.tiny)) - .foregroundStyle(.secondary) + .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) + .foregroundStyle(.tertiary) .padding(.horizontal, 5) .padding(.vertical, 1) - .background(Color(NSColor.quaternaryLabelColor).opacity(0.3)) + .background(.quaternary) .clipShape(Capsule()) } - // Line 2: type-aware editor + menu button - HStack(spacing: 4) { - typeAwareEditor - - fieldMenu - } + // Line 2: full-width editor with inline menu overlay + typeAwareEditor + .overlay(alignment: .topTrailing) { + fieldMenu + .opacity(isHovered ? 1 : 0) + .padding(.trailing, 4) + } } + .onHover { isHovered = $0 } } // MARK: - Type-Aware Editor @@ -79,32 +82,26 @@ struct EditableFieldView: View { @ViewBuilder private var typeAwareEditor: some View { if isPendingNull { - specialValueLabel("NULL") + TextField("NULL", text: .constant("")) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.small)) + .disabled(true) } else if isPendingDefault { - specialValueLabel("DEFAULT") + TextField("DEFAULT", text: .constant("")) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.small)) + .disabled(true) } else if columnTypeEnum.isBooleanType { booleanPicker } else if columnTypeEnum.isEnumType, let values = columnTypeEnum.enumValues, !values.isEmpty { enumPicker(values: values) - } else if isLongText || columnTypeEnum.isJsonType { + } else if isLongText { multiLineEditor } else { singleLineEditor } } - private func specialValueLabel(_ label: String) -> some View { - Text(label) - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.secondary) - .italic() - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - } - private var booleanPicker: some View { Picker("", selection: Binding( get: { normalizeBooleanValue(value) }, @@ -133,18 +130,11 @@ struct EditableFieldView: View { } private var multiLineEditor: some View { - TextEditor(text: $value) - .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) + TextField(placeholderText, text: $value, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.small)) + .lineLimit(3...6) .focused($isFocused) - .frame(height: 80) - .scrollContentBackground(.hidden) - .padding(4) - .background(Color(NSColor.textBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay( - RoundedRectangle(cornerRadius: 5) - .strokeBorder(Color(NSColor.separatorColor).opacity(0.5)) - ) } private var singleLineEditor: some View { @@ -203,11 +193,12 @@ struct EditableFieldView: View { } } label: { Image(systemName: "chevron.down") - .imageScale(.small) - .foregroundStyle(.secondary) + .font(.system(size: 10)) .frame(width: 20, height: 20) + .contentShape(Rectangle()) } - .menuStyle(.borderlessButton) + .menuStyle(.button) + .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() } @@ -236,7 +227,7 @@ struct EditableFieldView: View { } } -/// Read-only field view with compact layout +/// Read-only field view using native macOS components struct ReadOnlyFieldView: View { let columnName: String let columnType: String @@ -245,58 +236,49 @@ struct ReadOnlyFieldView: View { let value: String? var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { // Line 1: field name + type badge HStack(spacing: 4) { Text(columnName) - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .font(.system(size: DesignConstants.FontSize.small)) .lineLimit(1) Spacer() Text(columnTypeEnum.badgeLabel) - .font(.system(size: DesignConstants.FontSize.tiny)) - .foregroundStyle(.secondary) + .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) + .foregroundStyle(.tertiary) .padding(.horizontal, 5) .padding(.vertical, 1) - .background(Color(NSColor.quaternaryLabelColor).opacity(0.3)) + .background(.quaternary) .clipShape(Capsule()) } - // Line 2: value display - Group { - if let value { - if isLongText || columnTypeEnum.isJsonType { - Text(value) - .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, maxHeight: 80, alignment: .topLeading) - .clipped() - } else { - Text(value) - .font(.system(size: DesignConstants.FontSize.small)) - .textSelection(.enabled) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - } + // Line 2: value in disabled native text field + if let value { + if isLongText { + Text(value) + .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, maxHeight: 80, alignment: .topLeading) } else { - Text("NULL") + TextField("", text: .constant(value)) + .textFieldStyle(.roundedBorder) .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.tertiary) - .italic() - .frame(maxWidth: .infinity, alignment: .leading) + .disabled(true) } + } else { + TextField("NULL", text: .constant("")) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.small)) + .disabled(true) } - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .contextMenu { - if let value { - Button("Copy Value") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) - } + } + .contextMenu { + if let value { + Button("Copy Value") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) } } } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 40e098e3..92c8c26b 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -159,30 +159,33 @@ struct RightSidebarView: View { Divider() - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - // Section header + List { + Section { + if filtered.isEmpty && !searchText.isEmpty { + Text("No matching fields") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + } else { + ForEach(filtered, id: \.columnName) { field in + if contentMode == .editRow { + editableFieldRow(field, at: field.columnIndex) + } else { + readonlyFieldRow(field) + } + } + } + } header: { HStack { Text("FIELDS") - .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) - .foregroundStyle(.secondary) Spacer() Text("\(filtered.count)") - .font(.system(size: DesignConstants.FontSize.tiny)) - .foregroundStyle(.tertiary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - - ForEach(filtered, id: \.columnName) { field in - if contentMode == .editRow { - editableFieldRow(field, at: field.columnIndex) - } else { - readonlyFieldRow(field) - } + .foregroundStyle(.secondary) } + } - if contentMode == .editRow && editState.hasEdits { + if contentMode == .editRow && editState.hasEdits { + Section { Button(action: onSave) { Text("Save Changes") .frame(maxWidth: .infinity) @@ -190,61 +193,46 @@ struct RightSidebarView: View { .buttonStyle(.borderedProminent) .controlSize(.large) .keyboardShortcut("s", modifiers: .command) - .padding(.horizontal, 12) - .padding(.vertical, 8) } } } + .listStyle(.sidebar) } } @ViewBuilder private func editableFieldRow(_ field: FieldEditState, at index: Int) -> some View { - VStack(spacing: 0) { - EditableFieldView( - columnName: field.columnName, - columnType: field.columnType, - columnTypeEnum: field.columnTypeEnum, - isLongText: field.isLongText, - value: Binding( - get: { field.pendingValue ?? field.originalValue ?? "" }, - set: { editState.updateField(at: index, value: $0) } - ), - originalValue: field.originalValue, - hasMultipleValues: field.hasMultipleValues, - isPendingNull: field.isPendingNull, - isPendingDefault: field.isPendingDefault, - isModified: field.hasEdit, - onSetNull: { editState.setFieldToNull(at: index) }, - onSetDefault: { editState.setFieldToDefault(at: index) }, - onSetEmpty: { editState.setFieldToEmpty(at: index) }, - onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, - onUpdateValue: { editState.updateField(at: index, value: $0) } - ) - .padding(.horizontal, 12) - .padding(.vertical, 6) - - Divider() - .padding(.leading, 12) - } + EditableFieldView( + columnName: field.columnName, + columnType: field.columnType, + columnTypeEnum: field.columnTypeEnum, + isLongText: field.isLongText, + value: Binding( + get: { field.pendingValue ?? field.originalValue ?? "" }, + set: { editState.updateField(at: index, value: $0) } + ), + originalValue: field.originalValue, + hasMultipleValues: field.hasMultipleValues, + isPendingNull: field.isPendingNull, + isPendingDefault: field.isPendingDefault, + isModified: field.hasEdit, + onSetNull: { editState.setFieldToNull(at: index) }, + onSetDefault: { editState.setFieldToDefault(at: index) }, + onSetEmpty: { editState.setFieldToEmpty(at: index) }, + onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, + onUpdateValue: { editState.updateField(at: index, value: $0) } + ) } @ViewBuilder private func readonlyFieldRow(_ field: FieldEditState) -> some View { - VStack(spacing: 0) { - ReadOnlyFieldView( - columnName: field.columnName, - columnType: field.columnType, - columnTypeEnum: field.columnTypeEnum, - isLongText: field.isLongText, - value: field.originalValue - ) - .padding(.horizontal, 12) - .padding(.vertical, 6) - - Divider() - .padding(.leading, 12) - } + ReadOnlyFieldView( + columnName: field.columnName, + columnType: field.columnType, + columnTypeEnum: field.columnTypeEnum, + isLongText: field.isLongText, + value: field.originalValue + ) } } From ce8606ac47f3cb65caaf7d059bc70c51aafe9bbd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 2 Mar 2026 03:11:19 +0700 Subject: [PATCH 4/5] fix: sidebar-datagrid sync bugs and right sidebar UI polish --- .../ChangeTracking/DataChangeManager.swift | 23 +- TablePro/Extensions/String+JSON.swift | 24 + TablePro/Models/MultiRowEditState.swift | 26 +- TablePro/Views/MainContentView.swift | 24 + .../Views/Results/JSONEditorContentView.swift | 15 +- .../RightSidebar/EditableFieldView.swift | 41 +- .../Views/RightSidebar/RightSidebarView.swift | 53 +- .../Extensions/StringJsonTests.swift | 89 ++ .../Models/MultiRowEditStateTests.swift | 780 ++++++++++++++++++ 9 files changed, 998 insertions(+), 77 deletions(-) create mode 100644 TablePro/Extensions/String+JSON.swift create mode 100644 TableProTests/Extensions/StringJsonTests.swift create mode 100644 TableProTests/Models/MultiRowEditStateTests.swift diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index a9bb82af..d0cffe75 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -158,7 +158,28 @@ final class DataChangeManager: ObservableObject { newValue: String?, originalRow: [String?]? = nil ) { - guard oldValue != newValue else { return } + if oldValue == newValue { + let updateKey = RowChangeKey(rowIndex: rowIndex, type: .update) + if let existingIndex = changeIndex[updateKey], + let cellIndex = changes[existingIndex].cellChanges.firstIndex(where: { $0.columnIndex == columnIndex }) { + let originalOldValue = changes[existingIndex].cellChanges[cellIndex].oldValue + if originalOldValue == newValue { + changes[existingIndex].cellChanges.remove(at: cellIndex) + modifiedCells[rowIndex]?.remove(columnIndex) + if modifiedCells[rowIndex]?.isEmpty == true { modifiedCells.removeValue(forKey: rowIndex) } + if changes[existingIndex].cellChanges.isEmpty { removeChangeAt(existingIndex) } + } else { + changes[existingIndex].cellChanges[cellIndex] = CellChange( + rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, + oldValue: originalOldValue, newValue: newValue + ) + } + changedRowIndices.insert(rowIndex) + hasChanges = !changes.isEmpty + reloadVersion += 1 + } + return + } // New changes invalidate redo history (standard undo/redo behavior) if !isRedoing { undoManager.clearRedo() } diff --git a/TablePro/Extensions/String+JSON.swift b/TablePro/Extensions/String+JSON.swift new file mode 100644 index 00000000..0ebd2f86 --- /dev/null +++ b/TablePro/Extensions/String+JSON.swift @@ -0,0 +1,24 @@ +// +// String+JSON.swift +// TablePro +// +// JSON formatting utilities for string values. +// + +import Foundation + +extension String { + /// Returns a pretty-printed version of this string if it contains valid JSON, or nil otherwise. + func prettyPrintedAsJson() -> String? { + guard let data = data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: data), + let prettyData = try? JSONSerialization.data( + withJSONObject: jsonObject, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ), + let prettyString = String(data: prettyData, encoding: .utf8) else { + return nil + } + return prettyString + } +} diff --git a/TablePro/Models/MultiRowEditState.swift b/TablePro/Models/MultiRowEditState.swift index a890dd91..9eda398b 100644 --- a/TablePro/Models/MultiRowEditState.swift +++ b/TablePro/Models/MultiRowEditState.swift @@ -13,7 +13,6 @@ import Foundation struct FieldEditState { let columnIndex: Int let columnName: String - let columnType: String let columnTypeEnum: ColumnType let isLongText: Bool @@ -52,6 +51,8 @@ struct FieldEditState { class MultiRowEditState: ObservableObject { @Published var fields: [FieldEditState] = [] + var onFieldChanged: ((Int, String?) -> Void)? + private(set) var selectedRowIndices: Set = [] private(set) var allRows: [[String?]] = [] private(set) var columns: [String] = [] @@ -69,7 +70,8 @@ class MultiRowEditState: ObservableObject { columnTypes: [ColumnType] // Changed from [String] to [ColumnType] ) { // Check if the underlying data has changed (not just edits) - let dataChanged = self.allRows != allRows || self.columns != columns + let columnsChanged = self.columns != columns + let selectionChanged = self.selectedRowIndices != selectedRowIndices self.selectedRowIndices = selectedRowIndices self.allRows = allRows @@ -81,7 +83,6 @@ class MultiRowEditState: ObservableObject { for (colIndex, columnName) in columns.enumerated() { let columnTypeEnum = colIndex < columnTypes.count ? columnTypes[colIndex] : ColumnType.text(rawType: nil) - let columnType = columnTypeEnum.displayName let isLongText = columnTypeEnum.isLongText // Gather values from all selected rows @@ -108,17 +109,18 @@ class MultiRowEditState: ObservableObject { var isPendingNull = false var isPendingDefault = false - if !dataChanged, colIndex < fields.count { + if !columnsChanged, !selectionChanged, colIndex < fields.count { let oldField = fields[colIndex] - pendingValue = oldField.pendingValue - isPendingNull = oldField.isPendingNull - isPendingDefault = oldField.isPendingDefault + if oldField.originalValue == originalValue && oldField.hasMultipleValues == hasMultipleValues { + pendingValue = oldField.pendingValue + isPendingNull = oldField.isPendingNull + isPendingDefault = oldField.isPendingDefault + } } newFields.append(FieldEditState( columnIndex: colIndex, columnName: columnName, - columnType: columnType, columnTypeEnum: columnTypeEnum, isLongText: isLongText, originalValue: originalValue, @@ -135,6 +137,7 @@ class MultiRowEditState: ObservableObject { /// Update a field's pending value func updateField(at index: Int, value: String?) { guard index < fields.count else { return } + let hadPendingEdit = fields[index].hasEdit let original = fields[index].originalValue if value == original || (original == nil && value == "") { fields[index].pendingValue = nil @@ -143,6 +146,9 @@ class MultiRowEditState: ObservableObject { } fields[index].isPendingNull = false fields[index].isPendingDefault = false + if fields[index].pendingValue != nil || hadPendingEdit { + onFieldChanged?(index, value) + } } /// Set a field to NULL @@ -151,6 +157,7 @@ class MultiRowEditState: ObservableObject { fields[index].pendingValue = nil fields[index].isPendingNull = true fields[index].isPendingDefault = false + onFieldChanged?(index, nil) } /// Set a field to DEFAULT @@ -159,6 +166,7 @@ class MultiRowEditState: ObservableObject { fields[index].pendingValue = nil fields[index].isPendingNull = false fields[index].isPendingDefault = true + onFieldChanged?(index, "__DEFAULT__") } /// Set a field to a SQL function (e.g., NOW()) @@ -167,6 +175,7 @@ class MultiRowEditState: ObservableObject { fields[index].pendingValue = function fields[index].isPendingNull = false fields[index].isPendingDefault = false + onFieldChanged?(index, function) } /// Set a field to empty string @@ -175,6 +184,7 @@ class MultiRowEditState: ObservableObject { fields[index].pendingValue = "" fields[index].isPendingNull = false fields[index].isPendingDefault = false + onFieldChanged?(index, "") } /// Clear all pending edits diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index 2be15588..c1f80190 100644 --- a/TablePro/Views/MainContentView.swift +++ b/TablePro/Views/MainContentView.swift @@ -336,6 +336,7 @@ struct MainContentView: View { onCellEdit: { rowIndex, colIndex, value in coordinator.updateCellInTab( rowIndex: rowIndex, columnIndex: colIndex, value: value) + scheduleInspectorUpdate() }, onSort: { columnIndex, ascending, isMultiSort in coordinator.handleSort( @@ -771,6 +772,7 @@ struct MainContentView: View { !selectedRowIndices.isEmpty else { rightPanelState.editState.fields = [] + rightPanelState.editState.onFieldChanged = nil return } @@ -787,6 +789,28 @@ struct MainContentView: View { columns: tab.resultColumns, columnTypes: tab.columnTypes ) + + let capturedCoordinator = coordinator + let capturedIndices = selectedRowIndices + rightPanelState.editState.onFieldChanged = { columnIndex, newValue in + guard let tab = capturedCoordinator.tabManager.selectedTab else { return } + let columnName = columnIndex < tab.resultColumns.count ? tab.resultColumns[columnIndex] : "" + + for rowIndex in capturedIndices { + guard rowIndex < tab.resultRows.count else { continue } + let originalRow = tab.resultRows[rowIndex].values + let oldValue = columnIndex < originalRow.count ? originalRow[columnIndex] : nil + + capturedCoordinator.changeManager.recordCellChange( + rowIndex: rowIndex, + columnIndex: columnIndex, + columnName: columnName, + oldValue: oldValue, + newValue: newValue, + originalRow: originalRow + ) + } + } } // MARK: - Inspector Context diff --git a/TablePro/Views/Results/JSONEditorContentView.swift b/TablePro/Views/Results/JSONEditorContentView.swift index 4fddf24d..a5358a43 100644 --- a/TablePro/Views/Results/JSONEditorContentView.swift +++ b/TablePro/Views/Results/JSONEditorContentView.swift @@ -24,7 +24,7 @@ struct JSONEditorContentView: View { self.initialValue = initialValue self.onCommit = onCommit self.onDismiss = onDismiss - self._text = State(initialValue: Self.prettyPrint(initialValue) ?? initialValue ?? "") + self._text = State(initialValue: initialValue?.prettyPrintedAsJson() ?? initialValue ?? "") } var body: some View { @@ -79,19 +79,6 @@ struct JSONEditorContentView: View { // MARK: - JSON Helpers - private static func prettyPrint(_ jsonString: String?) -> String? { - guard let data = jsonString?.data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: data), - let prettyData = try? JSONSerialization.data( - withJSONObject: jsonObject, - options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - ), - let prettyString = String(data: prettyData, encoding: .utf8) else { - return nil - } - return prettyString - } - private static func compact(_ jsonString: String?) -> String? { guard let data = jsonString?.data(using: .utf8), let jsonObject = try? JSONSerialization.jsonObject(with: data), diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 7bb352f2..8566089b 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -6,13 +6,11 @@ // Two-line layout: field name + type badge, then native editor + menu. // -import AppKit import SwiftUI /// Compact editable field view using native macOS components struct EditableFieldView: View { let columnName: String - let columnType: String let columnTypeEnum: ColumnType let isLongText: Bool @Binding var value: String @@ -26,7 +24,6 @@ struct EditableFieldView: View { let onSetDefault: () -> Void let onSetEmpty: () -> Void let onSetFunction: (String) -> Void - let onUpdateValue: (String) -> Void @FocusState private var isFocused: Bool @State private var isHovered = false @@ -81,13 +78,8 @@ struct EditableFieldView: View { @ViewBuilder private var typeAwareEditor: some View { - if isPendingNull { - TextField("NULL", text: .constant("")) - .textFieldStyle(.roundedBorder) - .font(.system(size: DesignConstants.FontSize.small)) - .disabled(true) - } else if isPendingDefault { - TextField("DEFAULT", text: .constant("")) + if isPendingNull || isPendingDefault { + TextField(isPendingNull ? "NULL" : "DEFAULT", text: .constant("")) .textFieldStyle(.roundedBorder) .font(.system(size: DesignConstants.FontSize.small)) .disabled(true) @@ -105,7 +97,7 @@ struct EditableFieldView: View { private var booleanPicker: some View { Picker("", selection: Binding( get: { normalizeBooleanValue(value) }, - set: { onUpdateValue($0) } + set: { value = $0 } )) { Text("true").tag("1") Text("false").tag("0") @@ -118,7 +110,7 @@ struct EditableFieldView: View { private func enumPicker(values: [String]) -> some View { Picker("", selection: Binding( get: { value }, - set: { onUpdateValue($0) } + set: { value = $0 } )) { ForEach(values, id: \.self) { val in Text(val).tag(val) @@ -164,15 +156,14 @@ struct EditableFieldView: View { if columnTypeEnum.isJsonType { Button("Pretty Print") { - if let formatted = prettyPrintJson(value) { - onUpdateValue(formatted) + if let formatted = value.prettyPrintedAsJson() { + value = formatted } } } Button("Copy Value") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) + ClipboardService.shared.writeText(value) } Divider() @@ -188,7 +179,7 @@ struct EditableFieldView: View { if isPendingNull || isPendingDefault { Divider() Button("Clear") { - onUpdateValue(originalValue ?? "") + value = originalValue ?? "" } } } label: { @@ -213,24 +204,11 @@ struct EditableFieldView: View { return "0" } - private func prettyPrintJson(_ jsonString: String) -> String? { - guard let data = jsonString.data(using: .utf8), - let jsonObject = try? JSONSerialization.jsonObject(with: data), - let prettyData = try? JSONSerialization.data( - withJSONObject: jsonObject, - options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - ), - let prettyString = String(data: prettyData, encoding: .utf8) else { - return nil - } - return prettyString - } } /// Read-only field view using native macOS components struct ReadOnlyFieldView: View { let columnName: String - let columnType: String let columnTypeEnum: ColumnType let isLongText: Bool let value: String? @@ -277,8 +255,7 @@ struct ReadOnlyFieldView: View { .contextMenu { if let value { Button("Copy Value") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) + ClipboardService.shared.writeText(value) } } } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 92c8c26b..5ba1e88d 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -65,9 +65,15 @@ struct RightSidebarView: View { private func tableInfoContent(_ metadata: TableMetadata) -> some View { Form { Section { - LabeledContent(String(localized: "Data Size"), value: TableMetadata.formatSize(metadata.dataSize)) - LabeledContent(String(localized: "Index Size"), value: TableMetadata.formatSize(metadata.indexSize)) - LabeledContent(String(localized: "Total Size"), value: TableMetadata.formatSize(metadata.totalSize)) + LabeledContent( + String(localized: "Data Size"), + value: TableMetadata.formatSize(metadata.dataSize)) + LabeledContent( + String(localized: "Index Size"), + value: TableMetadata.formatSize(metadata.indexSize)) + LabeledContent( + String(localized: "Total Size"), + value: TableMetadata.formatSize(metadata.totalSize)) } header: { Text("SIZE") } @@ -129,10 +135,13 @@ struct RightSidebarView: View { private func rowDetailForm( _ rowData: [(column: String, value: String?, type: String)] ) -> some View { - let filtered = searchText.isEmpty ? editState.fields : editState.fields.filter { - $0.columnName.localizedCaseInsensitiveContains(searchText) || - ($0.originalValue?.localizedCaseInsensitiveContains(searchText) ?? false) - } + let filtered = + searchText.isEmpty + ? editState.fields + : editState.fields.filter { + $0.columnName.localizedCaseInsensitiveContains(searchText) + || ($0.originalValue?.localizedCaseInsensitiveContains(searchText) ?? false) + } return VStack(spacing: 0) { // Inline search field @@ -182,21 +191,24 @@ struct RightSidebarView: View { Text("\(filtered.count)") .foregroundStyle(.secondary) } + .padding(.trailing, 15) } - if contentMode == .editRow && editState.hasEdits { - Section { - Button(action: onSave) { - Text("Save Changes") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .keyboardShortcut("s", modifiers: .command) - } - } } .listStyle(.sidebar) + + if contentMode == .editRow && editState.hasEdits { + Divider() + Button(action: onSave) { + Text("Save Changes") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut("s", modifiers: .command) + .padding(.horizontal, 12) + .padding(.vertical, 8) + } } } @@ -204,7 +216,6 @@ struct RightSidebarView: View { private func editableFieldRow(_ field: FieldEditState, at index: Int) -> some View { EditableFieldView( columnName: field.columnName, - columnType: field.columnType, columnTypeEnum: field.columnTypeEnum, isLongText: field.isLongText, value: Binding( @@ -219,8 +230,7 @@ struct RightSidebarView: View { onSetNull: { editState.setFieldToNull(at: index) }, onSetDefault: { editState.setFieldToDefault(at: index) }, onSetEmpty: { editState.setFieldToEmpty(at: index) }, - onSetFunction: { editState.setFieldToFunction(at: index, function: $0) }, - onUpdateValue: { editState.updateField(at: index, value: $0) } + onSetFunction: { editState.setFieldToFunction(at: index, function: $0) } ) } @@ -228,7 +238,6 @@ struct RightSidebarView: View { private func readonlyFieldRow(_ field: FieldEditState) -> some View { ReadOnlyFieldView( columnName: field.columnName, - columnType: field.columnType, columnTypeEnum: field.columnTypeEnum, isLongText: field.isLongText, value: field.originalValue diff --git a/TableProTests/Extensions/StringJsonTests.swift b/TableProTests/Extensions/StringJsonTests.swift new file mode 100644 index 00000000..985a0726 --- /dev/null +++ b/TableProTests/Extensions/StringJsonTests.swift @@ -0,0 +1,89 @@ +// +// StringJsonTests.swift +// TableProTests +// +// Tests for String+JSON pretty-printing extension +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("String+JSON") +struct StringJsonTests { + @Test("Valid JSON object is pretty-printed with sorted keys") + func validJsonObject() { + let input = "{\"name\":\"Alice\",\"age\":30}" + let result = input.prettyPrintedAsJson() + + #expect(result != nil) + #expect(result!.contains("\n")) + let ageRange = result!.range(of: "age")! + let nameRange = result!.range(of: "name")! + #expect(ageRange.lowerBound < nameRange.lowerBound) + } + + @Test("Valid JSON array is pretty-printed") + func validJsonArray() { + let input = "[1,2,3]" + let result = input.prettyPrintedAsJson() + + #expect(result != nil) + #expect(result!.contains("\n")) + #expect(result!.contains("[")) + #expect(result!.contains("]")) + let expected = """ + [ + 1, + 2, + 3 + ] + """ + #expect(result == expected) + } + + @Test("Invalid JSON returns nil") + func invalidJson() { + let input = "not valid json at all" + let result = input.prettyPrintedAsJson() + + #expect(result == nil) + } + + @Test("Empty string returns nil") + func emptyString() { + let input = "" + let result = input.prettyPrintedAsJson() + + #expect(result == nil) + } + + @Test("Nested objects are correctly indented") + func nestedObjects() { + let input = "{\"user\":{\"address\":{\"city\":\"Hanoi\"}}}" + let result = input.prettyPrintedAsJson() + + #expect(result != nil) + let expected = """ + { + "user" : { + "address" : { + "city" : "Hanoi" + } + } + } + """ + #expect(result == expected) + } + + @Test("URLs are not escaped due to withoutEscapingSlashes") + func urlsNotEscaped() { + let input = "{\"url\":\"https://example.com/path/to/resource\"}" + let result = input.prettyPrintedAsJson() + + #expect(result != nil) + #expect(result!.contains("https://example.com/path/to/resource")) + #expect(!result!.contains("\\/")) + } +} diff --git a/TableProTests/Models/MultiRowEditStateTests.swift b/TableProTests/Models/MultiRowEditStateTests.swift new file mode 100644 index 00000000..d67ac906 --- /dev/null +++ b/TableProTests/Models/MultiRowEditStateTests.swift @@ -0,0 +1,780 @@ +// +// MultiRowEditStateTests.swift +// TableProTests +// +// Created on 2026-03-02. +// + +import Foundation +import Testing +@testable import TablePro + +@MainActor @Suite("MultiRowEditState") +struct MultiRowEditStateTests { + + // MARK: - Helper + + private func makeSUT( + columns: [String] = ["id", "name", "email"], + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [["1", "Alice", "alice@test.com"]], + selectedIndices: Set = [0] + ) -> MultiRowEditState { + let sut = MultiRowEditState() + let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } + sut.configure( + selectedRowIndices: selectedIndices, + allRows: rows, + columns: columns, + columnTypes: types + ) + return sut + } + + // MARK: - FieldEditState Computed Properties + + @MainActor @Suite("FieldEditState Computed Properties") + struct FieldEditStateTests { + + @Test("hasEdit is false when no pending changes") + func hasEditFalseWhenNoPendingChanges() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: nil, isPendingNull: false, isPendingDefault: false + ) + #expect(field.hasEdit == false) + } + + @Test("hasEdit is true when pendingValue is set") + func hasEditTrueWhenPendingValueSet() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: "2", isPendingNull: false, isPendingDefault: false + ) + #expect(field.hasEdit == true) + } + + @Test("hasEdit is true when isPendingNull is set") + func hasEditTrueWhenPendingNull() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: nil, isPendingNull: true, isPendingDefault: false + ) + #expect(field.hasEdit == true) + } + + @Test("hasEdit is true when isPendingDefault is set") + func hasEditTrueWhenPendingDefault() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: nil, isPendingNull: false, isPendingDefault: true + ) + #expect(field.hasEdit == true) + } + + @Test("effectiveValue returns pendingValue when set") + func effectiveValueReturnsPendingValue() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: "updated", isPendingNull: false, isPendingDefault: false + ) + #expect(field.effectiveValue == "updated") + } + + @Test("effectiveValue returns nil when isPendingNull") + func effectiveValueReturnsNilWhenPendingNull() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: nil, isPendingNull: true, isPendingDefault: false + ) + #expect(field.effectiveValue == nil) + } + + @Test("effectiveValue returns __DEFAULT__ when isPendingDefault") + func effectiveValueReturnsDefaultWhenPendingDefault() { + let field = FieldEditState( + columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), + isLongText: false, originalValue: "1", hasMultipleValues: false, + pendingValue: nil, isPendingNull: false, isPendingDefault: true + ) + #expect(field.effectiveValue == "__DEFAULT__") + } + } + + // MARK: - configure() + + @MainActor @Suite("configure()") + struct ConfigureTests { + + private func makeSUT( + columns: [String] = ["id", "name", "email"], + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [["1", "Alice", "alice@test.com"]], + selectedIndices: Set = [0] + ) -> MultiRowEditState { + let sut = MultiRowEditState() + let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } + sut.configure( + selectedRowIndices: selectedIndices, + allRows: rows, + columns: columns, + columnTypes: types + ) + return sut + } + + @Test("Creates fields matching columns count") + func fieldsMatchColumnsCount() { + let sut = makeSUT() + #expect(sut.fields.count == 3) + } + + @Test("Field names match column names") + func fieldNamesMatchColumnNames() { + let sut = makeSUT() + #expect(sut.fields[0].columnName == "id") + #expect(sut.fields[1].columnName == "name") + #expect(sut.fields[2].columnName == "email") + } + + @Test("Field indices match column indices") + func fieldIndicesMatchColumnIndices() { + let sut = makeSUT() + #expect(sut.fields[0].columnIndex == 0) + #expect(sut.fields[1].columnIndex == 1) + #expect(sut.fields[2].columnIndex == 2) + } + + @Test("Single row sets originalValue and hasMultipleValues false") + func singleRowOriginalValue() { + let sut = makeSUT( + rows: [["1", "Alice", "alice@test.com"]] + ) + #expect(sut.fields[0].originalValue == "1") + #expect(sut.fields[1].originalValue == "Alice") + #expect(sut.fields[2].originalValue == "alice@test.com") + #expect(sut.fields[0].hasMultipleValues == false) + #expect(sut.fields[1].hasMultipleValues == false) + #expect(sut.fields[2].hasMultipleValues == false) + } + + @Test("Multiple rows with same values sets originalValue and hasMultipleValues false") + func multipleRowsSameValues() { + let sut = makeSUT( + rows: [ + ["1", "Alice", "alice@test.com"], + ["1", "Alice", "alice@test.com"], + ] + ) + #expect(sut.fields[0].originalValue == "1") + #expect(sut.fields[1].originalValue == "Alice") + #expect(sut.fields[0].hasMultipleValues == false) + } + + @Test("Multiple rows with different values sets originalValue nil and hasMultipleValues true") + func multipleRowsDifferentValues() { + let sut = makeSUT( + rows: [ + ["1", "Alice", "alice@test.com"], + ["2", "Bob", "bob@test.com"], + ] + ) + #expect(sut.fields[0].originalValue == nil) + #expect(sut.fields[1].originalValue == nil) + #expect(sut.fields[0].hasMultipleValues == true) + #expect(sut.fields[1].hasMultipleValues == true) + } + + @Test("NULL values in rows sets originalValue to nil") + func nullValuesInRows() { + let sut = makeSUT( + columns: ["id", "name"], + rows: [[nil, nil]] + ) + #expect(sut.fields[0].originalValue == nil) + #expect(sut.fields[1].originalValue == nil) + #expect(sut.fields[0].hasMultipleValues == false) + } + + @Test("Missing column types uses fallback text type") + func missingColumnTypesFallback() { + let sut = MultiRowEditState() + sut.configure( + selectedRowIndices: [0], + allRows: [["1", "Alice"]], + columns: ["id", "name"], + columnTypes: [] + ) + #expect(sut.fields[0].columnTypeEnum == .text(rawType: nil)) + #expect(sut.fields[1].columnTypeEnum == .text(rawType: nil)) + } + + @Test("Empty columns creates empty fields") + func emptyColumnsCreatesEmptyFields() { + let sut = makeSUT(columns: [], rows: []) + #expect(sut.fields.isEmpty) + } + + @Test("Reconfigure with changed data clears that field's edit but preserves others") + func reconfigureChangedDataClearsAffectedFieldOnly() { + let sut = makeSUT( + rows: [["1", "Alice", "alice@test.com"]] + ) + sut.updateField(at: 0, value: "99") + sut.updateField(at: 1, value: "Bob") + #expect(sut.fields[0].pendingValue == "99") + #expect(sut.fields[1].pendingValue == "Bob") + + // Reconfigure with name changed in underlying data but id unchanged + sut.configure( + selectedRowIndices: [0], + allRows: [["1", "UpdatedName", "alice@test.com"]], + columns: ["id", "name", "email"], + columnTypes: [.text(rawType: nil), .text(rawType: nil), .text(rawType: nil)] + ) + // id field edit preserved (original unchanged) + #expect(sut.fields[0].pendingValue == "99") + // name field edit cleared (original changed from "Alice" to "UpdatedName") + #expect(sut.fields[1].pendingValue == nil) + } + + @Test("Reconfigure with same data preserves all edits") + func reconfigureSameDataPreservesEdits() { + let sut = makeSUT( + rows: [["1", "Alice", "alice@test.com"]] + ) + sut.updateField(at: 0, value: "99") + sut.setFieldToNull(at: 1) + sut.setFieldToDefault(at: 2) + + // Reconfigure with identical data + sut.configure( + selectedRowIndices: [0], + allRows: [["1", "Alice", "alice@test.com"]], + columns: ["id", "name", "email"], + columnTypes: [.text(rawType: nil), .text(rawType: nil), .text(rawType: nil)] + ) + #expect(sut.fields[0].pendingValue == "99") + #expect(sut.fields[1].isPendingNull == true) + #expect(sut.fields[2].isPendingDefault == true) + } + + @Test("Reconfigure with different columns clears all edits") + func reconfigureDifferentColumnsClearsAllEdits() { + let sut = makeSUT( + rows: [["1", "Alice", "alice@test.com"]] + ) + sut.updateField(at: 0, value: "99") + sut.updateField(at: 1, value: "Bob") + + // Reconfigure with different columns + sut.configure( + selectedRowIndices: [0], + allRows: [["x", "y"]], + columns: ["col_a", "col_b"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + #expect(sut.fields[0].pendingValue == nil) + #expect(sut.fields[1].pendingValue == nil) + } + + @Test("Reconfigure with different selection clears all edits") + func reconfigureDifferentSelectionClearsAllEdits() { + let sut = makeSUT( + rows: [["1", "Alice", "alice@test.com"]] + ) + sut.updateField(at: 0, value: "99") + + // Reconfigure with different selection + sut.configure( + selectedRowIndices: [1], + allRows: [["1", "Alice", "alice@test.com"]], + columns: ["id", "name", "email"], + columnTypes: [.text(rawType: nil), .text(rawType: nil), .text(rawType: nil)] + ) + #expect(sut.fields[0].pendingValue == nil) + } + } + + // MARK: - updateField() + + @MainActor @Suite("updateField()") + struct UpdateFieldTests { + + private func makeSUT( + columns: [String] = ["id", "name", "email"], + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [["1", "Alice", "alice@test.com"]], + selectedIndices: Set = [0] + ) -> MultiRowEditState { + let sut = MultiRowEditState() + let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } + sut.configure( + selectedRowIndices: selectedIndices, + allRows: rows, + columns: columns, + columnTypes: types + ) + return sut + } + + @Test("Sets pendingValue when different from original") + func setsPendingValueWhenDifferent() { + let sut = makeSUT() + sut.updateField(at: 1, value: "Bob") + #expect(sut.fields[1].pendingValue == "Bob") + } + + @Test("Clears pendingValue when reverting to original") + func clearsPendingValueWhenRevertingToOriginal() { + let sut = makeSUT() + sut.updateField(at: 1, value: "Bob") + #expect(sut.fields[1].pendingValue == "Bob") + + sut.updateField(at: 1, value: "Alice") + #expect(sut.fields[1].pendingValue == nil) + } + + @Test("Clears null and default flags when updating") + func clearsNullAndDefaultFlags() { + let sut = makeSUT() + sut.setFieldToNull(at: 0) + #expect(sut.fields[0].isPendingNull == true) + + sut.updateField(at: 0, value: "new") + #expect(sut.fields[0].isPendingNull == false) + #expect(sut.fields[0].isPendingDefault == false) + #expect(sut.fields[0].pendingValue == "new") + } + + @Test("Out-of-bounds index is no-op") + func outOfBoundsIndexNoOp() { + let sut = makeSUT() + sut.updateField(at: 99, value: "crash?") + #expect(sut.fields.count == 3) + } + + @Test("hasEdits true after edit and false after revert") + func hasEditsToggle() { + let sut = makeSUT() + #expect(sut.hasEdits == false) + + sut.updateField(at: 0, value: "changed") + #expect(sut.hasEdits == true) + + sut.updateField(at: 0, value: "1") + #expect(sut.hasEdits == false) + } + + @Test("Handles nil original with empty string revert") + func handlesNilOriginalWithEmptyStringRevert() { + let sut = makeSUT( + columns: ["name"], + rows: [[nil]] + ) + #expect(sut.fields[0].originalValue == nil) + + sut.updateField(at: 0, value: "") + // Empty string on nil original is treated as revert + #expect(sut.fields[0].pendingValue == nil) + } + + @Test("Sets value for multi-value field") + func setsValueForMultiValueField() { + let sut = makeSUT( + columns: ["name"], + rows: [["Alice"], ["Bob"]] + ) + #expect(sut.fields[0].hasMultipleValues == true) + #expect(sut.fields[0].originalValue == nil) + + sut.updateField(at: 0, value: "Charlie") + #expect(sut.fields[0].pendingValue == "Charlie") + } + + @Test("Overwrites existing pending value") + func overwritesExistingPendingValue() { + let sut = makeSUT() + sut.updateField(at: 0, value: "first") + #expect(sut.fields[0].pendingValue == "first") + + sut.updateField(at: 0, value: "second") + #expect(sut.fields[0].pendingValue == "second") + } + } + + // MARK: - setFieldToNull / setFieldToDefault / setFieldToFunction / setFieldToEmpty + + @MainActor @Suite("Set Field Special Values") + struct SetFieldSpecialValuesTests { + + private func makeSUT( + columns: [String] = ["id", "name", "email"], + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [["1", "Alice", "alice@test.com"]], + selectedIndices: Set = [0] + ) -> MultiRowEditState { + let sut = MultiRowEditState() + let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } + sut.configure( + selectedRowIndices: selectedIndices, + allRows: rows, + columns: columns, + columnTypes: types + ) + return sut + } + + @Test("setFieldToNull sets isPendingNull and clears others") + func setFieldToNullSetsFlag() { + let sut = makeSUT() + sut.updateField(at: 0, value: "temp") + sut.setFieldToNull(at: 0) + #expect(sut.fields[0].isPendingNull == true) + #expect(sut.fields[0].isPendingDefault == false) + #expect(sut.fields[0].pendingValue == nil) + } + + @Test("setFieldToDefault sets isPendingDefault and clears others") + func setFieldToDefaultSetsFlag() { + let sut = makeSUT() + sut.setFieldToNull(at: 0) + sut.setFieldToDefault(at: 0) + #expect(sut.fields[0].isPendingDefault == true) + #expect(sut.fields[0].isPendingNull == false) + #expect(sut.fields[0].pendingValue == nil) + } + + @Test("setFieldToFunction sets pendingValue to function string") + func setFieldToFunctionSetsPendingValue() { + let sut = makeSUT() + sut.setFieldToFunction(at: 0, function: "NOW()") + #expect(sut.fields[0].pendingValue == "NOW()") + #expect(sut.fields[0].isPendingNull == false) + #expect(sut.fields[0].isPendingDefault == false) + } + + @Test("setFieldToEmpty sets pendingValue to empty string") + func setFieldToEmptySetsPendingValue() { + let sut = makeSUT() + sut.setFieldToEmpty(at: 0) + #expect(sut.fields[0].pendingValue == "") + #expect(sut.fields[0].isPendingNull == false) + #expect(sut.fields[0].isPendingDefault == false) + } + + @Test("Each special set method makes hasEdit true") + func specialSetMethodsMakeHasEditTrue() { + let sut = makeSUT() + + sut.setFieldToNull(at: 0) + #expect(sut.fields[0].hasEdit == true) + + sut.setFieldToDefault(at: 1) + #expect(sut.fields[1].hasEdit == true) + + sut.setFieldToFunction(at: 2, function: "UUID()") + #expect(sut.fields[2].hasEdit == true) + + let sut2 = makeSUT() + sut2.setFieldToEmpty(at: 0) + #expect(sut2.fields[0].hasEdit == true) + } + } + + // MARK: - clearEdits() + + @MainActor @Suite("clearEdits()") + struct ClearEditsTests { + + private func makeSUT( + columns: [String] = ["id", "name", "email"], + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [["1", "Alice", "alice@test.com"]], + selectedIndices: Set = [0] + ) -> MultiRowEditState { + let sut = MultiRowEditState() + let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } + sut.configure( + selectedRowIndices: selectedIndices, + allRows: rows, + columns: columns, + columnTypes: types + ) + return sut + } + + @Test("Clears all pending state from all fields") + func clearsAllPendingState() { + let sut = makeSUT() + sut.updateField(at: 0, value: "changed") + sut.setFieldToNull(at: 1) + sut.setFieldToDefault(at: 2) + #expect(sut.hasEdits == true) + + sut.clearEdits() + #expect(sut.hasEdits == false) + for field in sut.fields { + #expect(field.pendingValue == nil) + #expect(field.isPendingNull == false) + #expect(field.isPendingDefault == false) + } + } + + @Test("Preserves original values after clearing") + func preservesOriginalValuesAfterClearing() { + let sut = makeSUT() + sut.updateField(at: 0, value: "changed") + sut.clearEdits() + + #expect(sut.fields[0].originalValue == "1") + #expect(sut.fields[1].originalValue == "Alice") + #expect(sut.fields[2].originalValue == "alice@test.com") + } + } + + // MARK: - getEditedFields() + + @MainActor @Suite("getEditedFields()") + struct GetEditedFieldsTests { + + private func makeSUT( + columns: [String] = ["id", "name", "email"], + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [["1", "Alice", "alice@test.com"]], + selectedIndices: Set = [0] + ) -> MultiRowEditState { + let sut = MultiRowEditState() + let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } + sut.configure( + selectedRowIndices: selectedIndices, + allRows: rows, + columns: columns, + columnTypes: types + ) + return sut + } + + @Test("Returns only edited fields") + func returnsOnlyEditedFields() { + let sut = makeSUT() + sut.updateField(at: 1, value: "Bob") + let edited = sut.getEditedFields() + #expect(edited.count == 1) + #expect(edited[0].columnIndex == 1) + #expect(edited[0].columnName == "name") + } + + @Test("Returns correct newValue for pending value edit") + func returnsCorrectNewValueForPendingEdit() { + let sut = makeSUT() + sut.updateField(at: 0, value: "42") + let edited = sut.getEditedFields() + #expect(edited.count == 1) + #expect(edited[0].newValue == "42") + } + + @Test("Returns nil newValue for null edit") + func returnsNilForNullEdit() { + let sut = makeSUT() + sut.setFieldToNull(at: 0) + let edited = sut.getEditedFields() + #expect(edited.count == 1) + #expect(edited[0].newValue == nil) + } + + @Test("Returns __DEFAULT__ for default edit") + func returnsDefaultForDefaultEdit() { + let sut = makeSUT() + sut.setFieldToDefault(at: 0) + let edited = sut.getEditedFields() + #expect(edited.count == 1) + #expect(edited[0].newValue == "__DEFAULT__") + } + + @Test("Returns empty array when no edits") + func returnsEmptyArrayWhenNoEdits() { + let sut = makeSUT() + let edited = sut.getEditedFields() + #expect(edited.isEmpty) + } + } + + // MARK: - onFieldChanged callback + + @MainActor @Suite("onFieldChanged Callback") + struct OnFieldChangedCallbackTests { + + private func makeSUT( + columns: [String] = ["id", "name", "email"], + columnTypes: [ColumnType]? = nil, + rows: [[String?]] = [["1", "Alice", "alice@test.com"]], + selectedIndices: Set = [0] + ) -> MultiRowEditState { + let sut = MultiRowEditState() + let types = columnTypes ?? columns.map { _ in ColumnType.text(rawType: nil) } + sut.configure( + selectedRowIndices: selectedIndices, + allRows: rows, + columns: columns, + columnTypes: types + ) + return sut + } + + @Test("updateField fires callback with index and value for new edit") + func updateFieldFiresCallbackForNewEdit() { + let sut = makeSUT() + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + sut.updateField(at: 1, value: "Bob") + #expect(callbackCalls.count == 1) + #expect(callbackCalls[0].index == 1) + #expect(callbackCalls[0].value == "Bob") + } + + @Test("updateField fires callback when reverting to original after having pending edit") + func updateFieldFiresCallbackWhenRevertingWithPriorEdit() { + let sut = makeSUT() + sut.updateField(at: 1, value: "Bob") + + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + // Revert back to original "Alice" -- should fire because hadPendingEdit was true + sut.updateField(at: 1, value: "Alice") + #expect(callbackCalls.count == 1) + #expect(callbackCalls[0].index == 1) + #expect(callbackCalls[0].value == "Alice") + } + + @Test("updateField does NOT fire callback when setting to original with no prior edit") + func updateFieldDoesNotFireCallbackWhenSettingToOriginalNoPriorEdit() { + let sut = makeSUT() + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + // Setting to same original value with no prior edit -- should NOT fire + sut.updateField(at: 1, value: "Alice") + #expect(callbackCalls.isEmpty) + } + + @Test("updateField fires callback when reverting from isPendingNull") + func updateFieldFiresCallbackWhenRevertingFromNull() { + let sut = makeSUT() + sut.setFieldToNull(at: 0) + + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + // Revert to original "1" -- hadPendingEdit was true (isPendingNull) + sut.updateField(at: 0, value: "1") + #expect(callbackCalls.count == 1) + #expect(callbackCalls[0].index == 0) + #expect(callbackCalls[0].value == "1") + } + + @Test("updateField fires callback when reverting from isPendingDefault") + func updateFieldFiresCallbackWhenRevertingFromDefault() { + let sut = makeSUT() + sut.setFieldToDefault(at: 0) + + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + // Revert to original "1" -- hadPendingEdit was true (isPendingDefault) + sut.updateField(at: 0, value: "1") + #expect(callbackCalls.count == 1) + #expect(callbackCalls[0].index == 0) + #expect(callbackCalls[0].value == "1") + } + + @Test("setFieldToNull fires callback with nil") + func setFieldToNullFiresCallback() { + let sut = makeSUT() + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + sut.setFieldToNull(at: 0) + #expect(callbackCalls.count == 1) + #expect(callbackCalls[0].index == 0) + #expect(callbackCalls[0].value == nil) + } + + @Test("setFieldToDefault fires callback with __DEFAULT__") + func setFieldToDefaultFiresCallback() { + let sut = makeSUT() + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + sut.setFieldToDefault(at: 0) + #expect(callbackCalls.count == 1) + #expect(callbackCalls[0].index == 0) + #expect(callbackCalls[0].value == "__DEFAULT__") + } + + @Test("setFieldToFunction fires callback with function string") + func setFieldToFunctionFiresCallback() { + let sut = makeSUT() + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + sut.setFieldToFunction(at: 0, function: "NOW()") + #expect(callbackCalls.count == 1) + #expect(callbackCalls[0].index == 0) + #expect(callbackCalls[0].value == "NOW()") + } + + @Test("setFieldToEmpty fires callback with empty string") + func setFieldToEmptyFiresCallback() { + let sut = makeSUT() + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + sut.setFieldToEmpty(at: 0) + #expect(callbackCalls.count == 1) + #expect(callbackCalls[0].index == 0) + #expect(callbackCalls[0].value == "") + } + + @Test("clearEdits does NOT fire callback") + func clearEditsDoesNotFireCallback() { + let sut = makeSUT() + sut.updateField(at: 0, value: "changed") + sut.setFieldToNull(at: 1) + + var callbackCalls: [(index: Int, value: String?)] = [] + sut.onFieldChanged = { index, value in + callbackCalls.append((index, value)) + } + + sut.clearEdits() + #expect(callbackCalls.isEmpty) + } + } +} From ed72583bb8113ae48264b1d9e7adf5f4f5e22f7a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 2 Mar 2026 03:18:41 +0700 Subject: [PATCH 5/5] fix: address review issues from sidebar-datagrid sync PR --- TablePro/Core/ChangeTracking/DataChangeManager.swift | 11 +++-------- TablePro/Models/MultiRowEditState.swift | 11 +++++++++-- TablePro/Views/MainContentView.swift | 4 ++-- TablePro/Views/RightSidebar/EditableFieldView.swift | 2 +- TablePro/Views/RightSidebar/RightSidebarView.swift | 2 +- TableProTests/Models/MultiRowEditStateTests.swift | 9 +++++++++ 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index d0cffe75..371ddef0 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -168,15 +168,10 @@ final class DataChangeManager: ObservableObject { modifiedCells[rowIndex]?.remove(columnIndex) if modifiedCells[rowIndex]?.isEmpty == true { modifiedCells.removeValue(forKey: rowIndex) } if changes[existingIndex].cellChanges.isEmpty { removeChangeAt(existingIndex) } - } else { - changes[existingIndex].cellChanges[cellIndex] = CellChange( - rowIndex: rowIndex, columnIndex: columnIndex, columnName: columnName, - oldValue: originalOldValue, newValue: newValue - ) + changedRowIndices.insert(rowIndex) + hasChanges = !changes.isEmpty + reloadVersion += 1 } - changedRowIndices.insert(rowIndex) - hasChanges = !changes.isEmpty - reloadVersion += 1 } return } diff --git a/TablePro/Models/MultiRowEditState.swift b/TablePro/Models/MultiRowEditState.swift index 9eda398b..8d4fb188 100644 --- a/TablePro/Models/MultiRowEditState.swift +++ b/TablePro/Models/MultiRowEditState.swift @@ -181,10 +181,17 @@ class MultiRowEditState: ObservableObject { /// Set a field to empty string func setFieldToEmpty(at index: Int) { guard index < fields.count else { return } - fields[index].pendingValue = "" + let hadPendingEdit = fields[index].hasEdit + if fields[index].originalValue == "" { + fields[index].pendingValue = nil + } else { + fields[index].pendingValue = "" + } fields[index].isPendingNull = false fields[index].isPendingDefault = false - onFieldChanged?(index, "") + if fields[index].pendingValue != nil || hadPendingEdit { + onFieldChanged?(index, "") + } } /// Clear all pending edits diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index c1f80190..a77e9a0f 100644 --- a/TablePro/Views/MainContentView.swift +++ b/TablePro/Views/MainContentView.swift @@ -791,12 +791,12 @@ struct MainContentView: View { ) let capturedCoordinator = coordinator - let capturedIndices = selectedRowIndices + let capturedEditState = rightPanelState.editState rightPanelState.editState.onFieldChanged = { columnIndex, newValue in guard let tab = capturedCoordinator.tabManager.selectedTab else { return } let columnName = columnIndex < tab.resultColumns.count ? tab.resultColumns[columnIndex] : "" - for rowIndex in capturedIndices { + for rowIndex in capturedEditState.selectedRowIndices { guard rowIndex < tab.resultRows.count else { continue } let originalRow = tab.resultRows[rowIndex].values let oldValue = columnIndex < originalRow.count ? originalRow[columnIndex] : nil diff --git a/TablePro/Views/RightSidebar/EditableFieldView.swift b/TablePro/Views/RightSidebar/EditableFieldView.swift index 8566089b..1b47e08c 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -87,7 +87,7 @@ struct EditableFieldView: View { booleanPicker } else if columnTypeEnum.isEnumType, let values = columnTypeEnum.enumValues, !values.isEmpty { enumPicker(values: values) - } else if isLongText { + } else if isLongText || columnTypeEnum.isJsonType { multiLineEditor } else { singleLineEditor diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 5ba1e88d..a863af32 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -176,7 +176,7 @@ struct RightSidebarView: View { .foregroundStyle(.tertiary) .frame(maxWidth: .infinity) } else { - ForEach(filtered, id: \.columnName) { field in + ForEach(filtered, id: \.columnIndex) { field in if contentMode == .editRow { editableFieldRow(field, at: field.columnIndex) } else { diff --git a/TableProTests/Models/MultiRowEditStateTests.swift b/TableProTests/Models/MultiRowEditStateTests.swift index d67ac906..01802e24 100644 --- a/TableProTests/Models/MultiRowEditStateTests.swift +++ b/TableProTests/Models/MultiRowEditStateTests.swift @@ -469,6 +469,15 @@ struct MultiRowEditStateTests { #expect(sut.fields[0].isPendingDefault == false) } + @Test("setFieldToEmpty does not create edit when original is already empty string") + func setFieldToEmptyNoOpWhenOriginalEmpty() { + let sut = makeSUT(columns: ["name"], rows: [[""]]) + sut.setFieldToEmpty(at: 0) + #expect(sut.fields[0].pendingValue == nil) + #expect(sut.fields[0].hasEdit == false) + #expect(sut.hasEdits == false) + } + @Test("Each special set method makes hasEdit true") func specialSetMethodsMakeHasEditTrue() { let sut = makeSUT()