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/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index a9bb82af..371ddef0 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -158,7 +158,23 @@ 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) } + 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/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/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 597ab136..8d4fb188 100644 --- a/TablePro/Models/MultiRowEditState.swift +++ b/TablePro/Models/MultiRowEditState.swift @@ -13,8 +13,8 @@ import Foundation 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? @@ -51,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] = [] @@ -68,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 @@ -80,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 @@ -107,17 +109,19 @@ 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, hasMultipleValues: hasMultipleValues, @@ -133,9 +137,18 @@ 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 hadPendingEdit = fields[index].hasEdit + 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 + if fields[index].pendingValue != nil || hadPendingEdit { + onFieldChanged?(index, value) + } } /// Set a field to NULL @@ -144,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 @@ -152,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()) @@ -160,6 +175,23 @@ class MultiRowEditState: ObservableObject { fields[index].pendingValue = function fields[index].isPendingNull = false fields[index].isPendingDefault = false + onFieldChanged?(index, function) + } + + /// Set a field to empty string + func setFieldToEmpty(at index: Int) { + guard index < fields.count else { return } + 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 + if fields[index].pendingValue != nil || hadPendingEdit { + onFieldChanged?(index, "") + } } /// Clear all pending edits 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/MainContentView.swift b/TablePro/Views/MainContentView.swift index 2be15588..a77e9a0f 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 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 capturedEditState.selectedRowIndices { + 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 22193dd0..1b47e08c 100644 --- a/TablePro/Views/RightSidebar/EditableFieldView.swift +++ b/TablePro/Views/RightSidebar/EditableFieldView.swift @@ -2,198 +2,262 @@ // EditableFieldView.swift // TablePro // -// Reusable editable field component for right sidebar. -// Native macOS form-style field with menu button. +// Compact, type-aware field editor for right sidebar. +// Two-line layout: field name + type badge, then native editor + menu. // import SwiftUI -/// Editable field view with native macOS styling +/// Compact editable field view using native macOS components struct EditableFieldView: View { let columnName: String - let columnType: String - let isLongText: Bool // NEW: Whether to use multi-line editor + let columnTypeEnum: ColumnType + let isLongText: Bool @Binding var value: String let originalValue: String? - let hasMultipleValues: Bool // Whether multiple selected rows have different values + let hasMultipleValues: Bool let isPendingNull: Bool let isPendingDefault: Bool let isModified: Bool let onSetNull: () -> Void let onSetDefault: () -> Void + let onSetEmpty: () -> Void let onSetFunction: (String) -> Void @FocusState private var isFocused: Bool - - private var displayValue: String { - if isPendingNull { - return "NULL" - } else if isPendingDefault { - return "DEFAULT" - } else { - return value - } - } + @State private var isHovered = false 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 { - return "" + return "NULL" } } var body: some View { VStack(alignment: .leading, spacing: 4) { + // 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)) + .lineLimit(1) + + Spacer() + + Text(columnTypeEnum.badgeLabel) + .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary) + .clipShape(Capsule()) } - 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) + // Line 2: full-width editor with inline menu overlay + typeAwareEditor + .overlay(alignment: .topTrailing) { + fieldMenu + .opacity(isHovered ? 1 : 0) + .padding(.trailing, 4) } + } + .onHover { isHovered = $0 } + } - Menu { - Button("Set NULL") { - onSetNull() - } + // MARK: - Type-Aware Editor - Button("Set DEFAULT") { - onSetDefault() - } + @ViewBuilder + private var typeAwareEditor: some View { + if isPendingNull || isPendingDefault { + TextField(isPendingNull ? "NULL" : "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 { + multiLineEditor + } else { + singleLineEditor + } + } - 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()") - } - } + private var booleanPicker: some View { + Picker("", selection: Binding( + get: { normalizeBooleanValue(value) }, + set: { value = $0 } + )) { + Text("true").tag("1") + Text("false").tag("0") + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + } - if isPendingNull || isPendingDefault { - Divider() - Button("Clear") { - value = originalValue ?? "" - } + private func enumPicker(values: [String]) -> some View { + Picker("", selection: Binding( + get: { value }, + set: { value = $0 } + )) { + ForEach(values, id: \.self) { val in + Text(val).tag(val) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var multiLineEditor: some View { + TextField(placeholderText, text: $value, axis: .vertical) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.small)) + .lineLimit(3...6) + .focused($isFocused) + } + + 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() + } + + Divider() + + if columnTypeEnum.isJsonType { + Button("Pretty Print") { + if let formatted = value.prettyPrintedAsJson() { + value = 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") { + ClipboardService.shared.writeText(value) + } + + 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") { + value = originalValue ?? "" + } + } + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 10)) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) } + .menuStyle(.button) + .buttonStyle(.plain) + .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" + } + } -/// Read-only field view (for readonly mode or deleted rows) +/// Read-only field view using native macOS components 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) { + // Line 1: field name + type badge HStack(spacing: 4) { Text(columnName) .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() - Text(columnType) - .font(.system(size: DesignConstants.FontSize.tiny)) + Text(columnTypeEnum.badgeLabel) + .font(.system(size: DesignConstants.FontSize.tiny, weight: .medium)) .foregroundStyle(.tertiary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary) + .clipShape(Capsule()) } - Group { + // Line 2: value in disabled native text field + if let value { if isLongText { - if let value = value { - Text(value) - .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, maxHeight: 120, alignment: .topLeading) - .clipped() - } else { - Text("NULL") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.tertiary) - .italic() - .frame(maxWidth: .infinity, maxHeight: 120, alignment: .topLeading) - } + Text(value) + .font(.system(size: DesignConstants.FontSize.small, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, maxHeight: 80, 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() - } + TextField("", text: .constant(value)) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.small)) + .disabled(true) + } + } else { + TextField("NULL", text: .constant("")) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.small)) + .disabled(true) + } + } + .contextMenu { + if let value { + Button("Copy Value") { + ClipboardService.shared.writeText(value) } } - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background(Color(NSColor.controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 5)) } } } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 6e7d1ee6..a863af32 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,55 +135,88 @@ 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 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() + + 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: \.columnIndex) { field in + if contentMode == .editRow { + editableFieldRow(field, at: field.columnIndex) + } else { + readonlyFieldRow(field) + } + } } + } header: { HStack { Text("FIELDS") Spacer() Text("\(filtered.count)") .foregroundStyle(.secondary) } + .padding(.trailing, 15) } + } + .listStyle(.sidebar) if contentMode == .editRow && editState.hasEdits { - Section { - Button(action: onSave) { - Text("Save Changes") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .keyboardShortcut("s", modifiers: .command) + Divider() + Button(action: onSave) { + Text("Save Changes") + .frame(maxWidth: .infinity) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut("s", modifiers: .command) + .padding(.horizontal, 12) + .padding(.vertical, 8) } } - .formStyle(.grouped) - .searchable(text: $searchText, prompt: "Filter") } @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 ?? "" }, @@ -190,6 +229,7 @@ struct RightSidebarView: View { 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) } ) } @@ -198,7 +238,7 @@ 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..01802e24 --- /dev/null +++ b/TableProTests/Models/MultiRowEditStateTests.swift @@ -0,0 +1,789 @@ +// +// 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("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() + + 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) + } + } +}