diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de480233..a89c38790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The row details inspector now shows the selected row's values, including JSON, when a column value filter is active, and a JSON or serialized value you open now follows the selected row as you move between rows. (#1837) +- Copying, duplicating, and deleting rows now act on the rows you selected when a column value filter is active, instead of the rows sitting at those positions in the unfiltered result. (#1837) + ## [0.56.0] - 2026-07-09 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 90a203630..4ca28f14a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,8 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. **Schema loading**: `SQLSchemaProvider` (actor) stores an in-flight `loadTask: Task?`. Concurrent callers `await` the same Task instead of firing duplicate `fetchTables()` queries. Never use a boolean `isLoading` guard that returns without data — callers need to await the result. +**Selection indices are display positions**: `GridSelectionState.indices` come from `NSTableView.selectedRowIndexes` and are display-row positions, not indices into `TableRows.rows`. They match array indices only when `displayIDs` (`valueFilteredIDs ?? sortedIDs`) is nil; a per-column value filter makes them diverge. Resolve any selected index through `DisplayRowMapping` (or `TableViewCoordinator.displayRow(at:)` / `tableRowsIndex(forDisplayRow:)`) before reading or mutating a row; never index `TableRows.rows` with a display position. The row details inspector shipped this bug (#1837). + ### Main Coordinator Pattern `MainContentCoordinator` is the central coordinator, split across 7+ extension files in `Views/Main/Extensions/` (e.g., `+Alerts`, `+Filtering`, `+Pagination`, `+RowOperations`). When adding coordinator functionality, add a new extension file rather than growing the main file. diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 485f8e7d8..cdba4e287 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -53,6 +53,11 @@ final class RowEditingCoordinator { tab.tableContext.isEditable, !indices.isEmpty else { return } + if parent.activeGridDisplayIDs != nil { + deleteFilteredRows(indices: indices, tab: tab, tabIndex: tabIndex) + return + } + let tabId = tab.id var deleteResult = RowOperationsManager.DeleteRowsResult( @@ -85,12 +90,68 @@ final class RowEditingCoordinator { } } + private func deleteFilteredRows(indices: Set, tab: QueryTab, tabIndex: Int) { + let tabId = tab.id + let displayIDs = parent.activeGridDisplayIDs + let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) + + var existingRows: [(displayIndex: Int, originalRow: [PluginCellValue])] = [] + var insertedStorageIndices: [Int] = [] + for displayIndex in indices { + guard let storageIndex = DisplayRowMapping.rowIndex( + forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows + ) else { continue } + let row = tableRows.rows[storageIndex] + if row.id.isInserted { + insertedStorageIndices.append(storageIndex) + } else if !parent.changeManager.isRowDeleted(displayIndex) { + existingRows.append((displayIndex: displayIndex, originalRow: Array(row.values))) + } + } + + guard !existingRows.isEmpty || !insertedStorageIndices.isEmpty else { return } + + var deleteResult = RowOperationsManager.DeleteRowsResult( + nextRowToSelect: -1, physicallyRemovedIndices: [], delta: .none + ) + parent.mutateActiveTableRows(for: tabId) { rows in + let result = parent.rowOperationsManager.deleteRows( + existingRows: existingRows, + insertedStorageIndices: insertedStorageIndices, + tableRows: &rows + ) + deleteResult = result + return result.delta + } + + parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } + + if !deleteResult.physicallyRemovedIndices.isEmpty { + parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(deleteResult.delta) + } else { + parent.dataTabDelegate?.tableViewCoordinator?.invalidateCachesForUndoRedo() + } + + let displayCount = parent.dataTabDelegate?.tableViewCoordinator?.displayIDs?.count + ?? parent.tabSessionRegistry.tableRows(for: tabId).count + if let minSelected = indices.min(), displayCount > 0 { + parent.selectionState.indices = [min(minSelected, displayCount - 1)] + } else { + parent.selectionState.indices = [] + } + } + func duplicateSelectedRow(index: Int) { guard !parent.safeModeLevel.blocksAllWrites, let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, tab.tableContext.isEditable, tab.tableContext.tableName != nil else { return } + if parent.activeGridDisplayIDs != nil { + duplicateFilteredRow(displayIndex: index, tab: tab, tabIndex: tabIndex) + return + } + let tabId = tab.id let columns = parent.tabSessionRegistry.tableRows(for: tabId).columns guard index >= 0, index < parent.tabSessionRegistry.tableRows(for: tabId).count else { return } @@ -116,6 +177,40 @@ final class RowEditingCoordinator { parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) } + private func duplicateFilteredRow(displayIndex: Int, tab: QueryTab, tabIndex: Int) { + let tabId = tab.id + let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) + let columns = tableRows.columns + guard let storageIndex = DisplayRowMapping.rowIndex( + forDisplay: displayIndex, displayIDs: parent.activeGridDisplayIDs, in: tableRows + ), storageIndex >= 0, storageIndex < tableRows.count else { return } + + parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() + + var dupResult: RowOperationsManager.AddNewRowResult? + parent.mutateActiveTableRows(for: tabId) { rows in + let result = parent.rowOperationsManager.duplicateRow( + sourceRowIndex: storageIndex, + columns: columns, + tableRows: &rows + ) + dupResult = result + return result?.delta ?? .none + } + + guard let result = dupResult else { return } + + parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } + parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) + + let displayCount = parent.dataTabDelegate?.tableViewCoordinator?.displayIDs?.count + ?? parent.tabSessionRegistry.tableRows(for: tabId).count + let newDisplayIndex = displayCount - 1 + guard newDisplayIndex >= 0 else { return } + parent.selectionState.indices = [newDisplayIndex] + parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: newDisplayIndex, column: 0) + } + func undoInsertRow(at rowIndex: Int) { guard let (tab, _) = parent.tabManager.selectedTabAndIndex else { return } let tabId = tab.id @@ -165,6 +260,7 @@ final class RowEditingCoordinator { parent.rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, tableRows: tableRows, + displayIDs: parent.activeGridDisplayIDs, visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() ) } @@ -175,6 +271,7 @@ final class RowEditingCoordinator { parent.rowOperationsManager.copySelectedRowsToClipboard( selectedIndices: indices, tableRows: tableRows, + displayIDs: parent.activeGridDisplayIDs, includeHeaders: true, visibleColumnIndices: parent.dataTabDelegate?.tableViewCoordinator?.visibleColumnDataIndices() ) diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 3f84af036..cc694fa6f 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -151,6 +151,32 @@ final class RowOperationsManager { ) } + func deleteRows( + existingRows: [(displayIndex: Int, originalRow: [PluginCellValue])], + insertedStorageIndices: [Int], + tableRows: inout TableRows + ) -> DeleteRowsResult { + let sortedInsertedRows = insertedStorageIndices.sorted(by: >) + + var delta: Delta = .none + if !sortedInsertedRows.isEmpty { + delta = tableRows.remove(at: IndexSet(sortedInsertedRows)) + changeManager.undoBatchRowInsertion(rowIndices: sortedInsertedRows) + } + + if !existingRows.isEmpty { + changeManager.recordBatchRowDeletion( + rows: existingRows.map { (rowIndex: $0.displayIndex, originalRow: $0.originalRow) } + ) + } + + return DeleteRowsResult( + nextRowToSelect: -1, + physicallyRemovedIndices: sortedInsertedRows, + delta: delta + ) + } + func applyUndoResult(_ result: UndoResult, tableRows: inout TableRows) -> UndoApplicationResult { switch result.action { case .cellEdit(let rowIndex, let columnIndex, _, let previousValue, _, _): @@ -231,6 +257,7 @@ final class RowOperationsManager { func copySelectedRowsToClipboard( selectedIndices: Set, tableRows: TableRows, + displayIDs: [RowID]? = nil, includeHeaders: Bool = false, visibleColumnIndices: [Int]? = nil ) { @@ -263,10 +290,11 @@ final class RowOperationsManager { } } - for rowIndex in indicesToCopy { - guard rowIndex < tableRows.count else { continue } + for displayIndex in indicesToCopy { + guard let row = DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) + else { continue } if !result.isEmpty { result.append("\n") } - let cells = projection.values(Array(tableRows.rows[rowIndex].values)) + let cells = projection.values(Array(row.values)) structuredRows.append(cells) for (colIdx, cell) in cells.enumerated() { if colIdx > 0 { result.append("\t") } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index dbccc0b66..54d1f7eec 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -16,11 +16,13 @@ extension MainContentView { var selectedRowDataForSidebar: [(column: String, value: String?, type: String)]? { guard let tab = coordinator.tabManager.selectedTab, !coordinator.selectionState.indices.isEmpty, - let firstIndex = coordinator.selectionState.indices.min() else { return nil } + let firstDisplayIndex = coordinator.selectionState.indices.min() else { return nil } let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) - guard firstIndex < tableRows.rows.count else { return nil } - - let row = tableRows.rows[firstIndex].values + guard let row = DisplayRowMapping.row( + forDisplay: firstDisplayIndex, + displayIDs: coordinator.activeGridDisplayIDs, + in: tableRows + )?.values else { return nil } var data: [(column: String, value: String?, type: String)] = [] let service = ValueDisplayFormatService.shared diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index cb833267e..7951dea73 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -159,10 +159,11 @@ extension MainContentView { } let tableRows = coordinator.tabSessionRegistry.tableRows(for: tab.id) + let displayIDs = coordinator.activeGridDisplayIDs var allRows: [[PluginCellValue]] = [] - for index in selectedIndices.sorted() { - if index < tableRows.rows.count { - allRows.append(Array(tableRows.rows[index].values)) + for displayIndex in selectedIndices.sorted() { + if let row = DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) { + allRows.append(Array(row.values)) } } @@ -222,9 +223,14 @@ extension MainContentView { let columnName = columnIndex < tableRows.columns.count ? tableRows.columns[columnIndex] : "" + let displayIDs = capturedCoordinator.activeGridDisplayIDs for rowIndex in capturedEditState.selectedRowIndices { - guard rowIndex < tableRows.rows.count else { continue } - let originalRow = Array(tableRows.rows[rowIndex].values) + guard let resolvedRow = DisplayRowMapping.row( + forDisplay: rowIndex, + displayIDs: displayIDs, + in: tableRows + ) else { continue } + let originalRow = Array(resolvedRow.values) let oldValue: PluginCellValue if columnIndex < capturedEditState.fields.count { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index e37f03b43..22ae74677 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -134,6 +134,10 @@ final class MainContentCoordinator { /// dispatch insertRows/removeRows directly to the NSTableView via DataGridViewDelegate. @ObservationIgnored weak var dataTabDelegate: DataTabGridDelegate? + var activeGridDisplayIDs: [RowID]? { + dataTabDelegate?.tableViewCoordinator?.displayIDs + } + /// One-shot intent set when the user explicitly opens a table (Return/double-click), /// consumed by the grid as it appears to move focus into it. Never set on mere selection. @ObservationIgnored var pendingGridFocusOnOpen = false diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 62bccb60c..f13aa5cbb 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -367,22 +367,11 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } func displayRow(at displayIndex: Int, in tableRows: TableRows) -> Row? { - if let displayIDs { - guard displayIndex >= 0, displayIndex < displayIDs.count else { return nil } - return tableRows.row(withID: displayIDs[displayIndex]) - } - guard displayIndex >= 0, displayIndex < tableRows.count else { return nil } - return tableRows.rows[displayIndex] + DisplayRowMapping.row(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) } func tableRowsIndex(forDisplayRow displayIndex: Int) -> Int? { - if let displayIDs { - guard displayIndex >= 0, displayIndex < displayIDs.count else { return nil } - return tableRowsProvider().index(of: displayIDs[displayIndex]) - } - let count = tableRowsProvider().count - guard displayIndex >= 0, displayIndex < count else { return nil } - return displayIndex + DisplayRowMapping.rowIndex(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRowsProvider()) } func displayValue(forID id: RowID, column: Int, rawValue: PluginCellValue, columnType: ColumnType?) -> String? { diff --git a/TablePro/Views/Results/DisplayRowMapping.swift b/TablePro/Views/Results/DisplayRowMapping.swift new file mode 100644 index 000000000..a076a4da2 --- /dev/null +++ b/TablePro/Views/Results/DisplayRowMapping.swift @@ -0,0 +1,22 @@ +// +// DisplayRowMapping.swift +// TablePro +// + +import Foundation + +enum DisplayRowMapping { + static func rowIndex(forDisplay displayIndex: Int, displayIDs: [RowID]?, in tableRows: TableRows) -> Int? { + guard let displayIDs else { + guard displayIndex >= 0, displayIndex < tableRows.count else { return nil } + return displayIndex + } + guard displayIndex >= 0, displayIndex < displayIDs.count else { return nil } + return tableRows.index(of: displayIDs[displayIndex]) + } + + static func row(forDisplay displayIndex: Int, displayIDs: [RowID]?, in tableRows: TableRows) -> Row? { + guard let index = rowIndex(forDisplay: displayIndex, displayIDs: displayIDs, in: tableRows) else { return nil } + return tableRows.rows[index] + } +} diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 8a1ef0633..a8186e936 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -18,8 +18,8 @@ struct RightSidebarView: View { let databaseType: DatabaseType @State private var searchText: String = "" - @State private var expandedJsonFieldId: UUID? - @State private var expandedPhpFieldId: UUID? + @State private var expandedJsonColumnIndex: Int? + @State private var expandedPhpColumnIndex: Int? // MARK: - Inspector Mode @@ -141,14 +141,23 @@ struct RightSidebarView: View { private func rowDetailForm( _ rowData: [(column: String, value: String?, type: String)] ) -> some View { - if let expandedId = expandedJsonFieldId, - let field = editState.fields.first(where: { $0.id == expandedId }) { + rowDetailContent(rowData) + .onChange(of: rowData.map(\.column)) { + expandedJsonColumnIndex = nil + expandedPhpColumnIndex = nil + } + } + + @ViewBuilder + private func rowDetailContent( + _ rowData: [(column: String, value: String?, type: String)] + ) -> some View { + if let columnIndex = expandedJsonColumnIndex, + let field = editState.fields.first(where: { $0.columnIndex == columnIndex }) { expandedJsonViewer(field: field, isEditable: contentMode == .editRow) - .onChange(of: selectedRowData?.count) { expandedJsonFieldId = nil } - } else if let expandedId = expandedPhpFieldId, - let field = editState.fields.first(where: { $0.id == expandedId }) { + } else if let columnIndex = expandedPhpColumnIndex, + let field = editState.fields.first(where: { $0.columnIndex == columnIndex }) { expandedPhpViewer(field: field) - .onChange(of: selectedRowData?.count) { expandedPhpFieldId = nil } } else { fieldListForm(rowData) } @@ -159,7 +168,7 @@ struct RightSidebarView: View { private func expandedJsonViewer(field: FieldEditState, isEditable: Bool) -> some View { VStack(spacing: 0) { HStack { - Button { expandedJsonFieldId = nil } label: { + Button { expandedJsonColumnIndex = nil } label: { HStack(spacing: 4) { Image(systemName: "chevron.left") Text("Fields") @@ -218,7 +227,7 @@ struct RightSidebarView: View { private func expandedPhpViewer(field: FieldEditState) -> some View { VStack(spacing: 0) { HStack { - Button { expandedPhpFieldId = nil } label: { + Button { expandedPhpColumnIndex = nil } label: { HStack(spacing: 4) { Image(systemName: "chevron.left") Text("Fields") @@ -342,9 +351,9 @@ struct RightSidebarView: View { isForeignKey: field.isForeignKey, onExpand: isStructuredField ? { if isJsonField { - expandedJsonFieldId = field.id + expandedJsonColumnIndex = field.columnIndex } else { - expandedPhpFieldId = field.id + expandedPhpColumnIndex = field.columnIndex } } : nil, onPopOut: isStructuredField ? { currentText in diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index c3bf4bac2..2203a1a9d 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -299,4 +299,49 @@ struct RowOperationsManagerCopyTests { #expect(clipboard.lastWrittenGridRows?.columns == ["email", "id"]) #expect(clipboard.lastWrittenGridRows?.rows == [[.text("alice@test.com"), .text("1")]]) } + + @Test("Copy under a display filter reads the underlying row, not the raw position") + func copyResolvesDisplayIndexUnderFilter() { + let (manager, _) = makeManager() + let rows: [[String?]] = [ + ["1", "Alice", "a@test.com"], + ["2", "Bob", "b@test.com"], + ["3", "Carol", "c@test.com"], + ["4", "Dave", "d@test.com"], + ] + let clipboard = MockClipboardProvider() + ClipboardService.shared = clipboard + let tableRows = makeTableRows(rows: rows) + let displayIDs: [RowID] = [.existing(0), .existing(2), .existing(3)] + + manager.copySelectedRowsToClipboard( + selectedIndices: [1], + tableRows: tableRows, + displayIDs: displayIDs + ) + + #expect(clipboard.lastWrittenText == "3\tCarol\tc@test.com") + } + + @Test("Copy under a display filter emits rows in display order") + func copyEmitsDisplayOrderUnderFilter() { + let (manager, _) = makeManager() + let rows: [[String?]] = [ + ["1", "Alice", "a@test.com"], + ["2", "Bob", "b@test.com"], + ["3", "Carol", "c@test.com"], + ] + let clipboard = MockClipboardProvider() + ClipboardService.shared = clipboard + let tableRows = makeTableRows(rows: rows) + let displayIDs: [RowID] = [.existing(2), .existing(0)] + + manager.copySelectedRowsToClipboard( + selectedIndices: [0, 1], + tableRows: tableRows, + displayIDs: displayIDs + ) + + #expect(clipboard.lastWrittenText == "3\tCarol\tc@test.com\n1\tAlice\ta@test.com") + } } diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index 67790adc0..a8cd25159 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -347,6 +347,51 @@ struct RowOperationsManagerTests { #expect(tableRows.count == 3) } + @Test("deleteRows marks an existing row deleted at its display index with the given values") + func deleteRowsMarksExistingByDisplayIndex() { + let (manager, changeManager) = makeManager() + var tableRows = makeTableRows(rowCount: 4) + let resolvedRow: [PluginCellValue] = [.text("42"), .text("Zoe"), .text("zoe@test.com")] + + _ = manager.deleteRows( + existingRows: [(displayIndex: 1, originalRow: resolvedRow)], + insertedStorageIndices: [], + tableRows: &tableRows + ) + + #expect(changeManager.isRowDeleted(1)) + #expect(tableRows.count == 4) + let deleteChange = changeManager.rowChanges.first { $0.type == .delete && $0.rowIndex == 1 } + #expect(deleteChange?.originalRow == resolvedRow) + } + + @Test("deleteRows physically removes inserted rows by storage index") + func deleteRowsRemovesInsertedByStorageIndex() { + let (manager, _) = makeManager() + var tableRows = makeTableRows(rowCount: 3) + guard let addResult = manager.addNewRow( + columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows + ) else { + Issue.record("addNewRow returned nil") + return + } + #expect(tableRows.count == 4) + + let result = manager.deleteRows( + existingRows: [], + insertedStorageIndices: [addResult.rowIndex], + tableRows: &tableRows + ) + + #expect(tableRows.count == 3) + #expect(result.physicallyRemovedIndices == [addResult.rowIndex]) + if case .rowsRemoved(let indices) = result.delta { + #expect(indices == IndexSet(integer: addResult.rowIndex)) + } else { + Issue.record("Expected .rowsRemoved delta") + } + } + @Test("addNewRow then edit cell preserves insertion state") func addNewRowThenEditPreservesInsertion() { let (manager, changeManager) = makeManager() diff --git a/TableProTests/Models/MultiRowEditStateTests.swift b/TableProTests/Models/MultiRowEditStateTests.swift index bce4b32db..eb022728e 100644 --- a/TableProTests/Models/MultiRowEditStateTests.swift +++ b/TableProTests/Models/MultiRowEditStateTests.swift @@ -312,6 +312,27 @@ struct MultiRowEditStateTests { #expect(sut.fields[0].pendingValue == nil) } + @Test("Reconfigure to a new row keeps a stable columnIndex resolving to the new value") + func reconfigureNewRowResolvesByColumnIndex() { + let sut = makeSUT( + columns: ["id", "payload"], + rows: [["1", "{\"reached\":\"07:53\"}"]], + selectedIndices: [0] + ) + #expect(sut.fields.first(where: { $0.columnIndex == 1 })?.originalValue == "{\"reached\":\"07:53\"}") + + sut.configure( + selectedRowIndices: [1], + allRows: [["2", "{\"reached\":\"08:40\"}"]], + columns: ["id", "payload"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + + let payloadField = sut.fields.first(where: { $0.columnIndex == 1 }) + #expect(payloadField != nil) + #expect(payloadField?.originalValue == "{\"reached\":\"08:40\"}") + } + @Test("Reconfigure with added column clears all edits") func reconfigureWithAddedColumnClearsAllEdits() { let sut = makeSUT( diff --git a/TableProTests/Views/Results/DisplayRowMappingTests.swift b/TableProTests/Views/Results/DisplayRowMappingTests.swift new file mode 100644 index 000000000..4773035d2 --- /dev/null +++ b/TableProTests/Views/Results/DisplayRowMappingTests.swift @@ -0,0 +1,61 @@ +// +// DisplayRowMappingTests.swift +// TableProTests +// + +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("DisplayRowMapping") +struct DisplayRowMappingTests { + private func makeTableRows() -> TableRows { + let rows: ContiguousArray = [ + Row(id: .existing(0), values: [.text("active"), .text("a")]), + Row(id: .existing(1), values: [.text("inactive"), .text("b")]), + Row(id: .existing(2), values: [.text("active"), .text("c")]), + Row(id: .existing(3), values: [.null, .text("d")]), + ] + return TableRows( + rows: rows, + columns: ["status", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + } + + @Test("with no display filter, a display index maps to the same array index") + func identityMappingWithoutFilter() { + let tableRows = makeTableRows() + #expect(DisplayRowMapping.rowIndex(forDisplay: 0, displayIDs: nil, in: tableRows) == 0) + #expect(DisplayRowMapping.rowIndex(forDisplay: 2, displayIDs: nil, in: tableRows) == 2) + #expect(DisplayRowMapping.row(forDisplay: 3, displayIDs: nil, in: tableRows)?.id == .existing(3)) + } + + @Test("out-of-range display index returns nil without a filter") + func outOfRangeWithoutFilter() { + let tableRows = makeTableRows() + #expect(DisplayRowMapping.rowIndex(forDisplay: 4, displayIDs: nil, in: tableRows) == nil) + #expect(DisplayRowMapping.rowIndex(forDisplay: -1, displayIDs: nil, in: tableRows) == nil) + } + + @Test("a filtered display position resolves to the underlying array row, not the raw position") + func filteredMappingResolvesUnderlyingRow() { + let tableRows = makeTableRows() + let displayIDs: [RowID] = [.existing(0), .existing(2)] + + #expect(DisplayRowMapping.rowIndex(forDisplay: 0, displayIDs: displayIDs, in: tableRows) == 0) + #expect(DisplayRowMapping.rowIndex(forDisplay: 1, displayIDs: displayIDs, in: tableRows) == 2) + + let resolved = DisplayRowMapping.row(forDisplay: 1, displayIDs: displayIDs, in: tableRows) + #expect(resolved?.id == .existing(2)) + #expect(resolved?.values == tableRows.rows[2].values) + } + + @Test("out-of-range display index returns nil with a filter") + func outOfRangeWithFilter() { + let tableRows = makeTableRows() + let displayIDs: [RowID] = [.existing(0), .existing(2)] + #expect(DisplayRowMapping.row(forDisplay: 2, displayIDs: displayIDs, in: tableRows) == nil) + } +}