Skip to content

Commit 8b18b9a

Browse files
committed
refactor(datagrid): decouple cell-edit model mutation for one-reload column fill (#1304)
1 parent 1f749a6 commit 8b18b9a

3 files changed

Lines changed: 140 additions & 34 deletions

File tree

TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,39 @@ extension TableViewCoordinator {
1515
}
1616

1717
func commitTypedCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) {
18-
cellCommitLogger.debug("commitTypedCellEdit(row: \(row, privacy: .public), columnIndex: \(columnIndex, privacy: .public)) isCommitting=\(self.isCommittingCellEdit, privacy: .public) delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)")
19-
guard !isCommittingCellEdit else { return }
2018
guard let tableView else { return }
19+
guard let delta = recordCellEdit(row: row, columnIndex: columnIndex, newValue: typedNewValue) else { return }
20+
21+
invalidateDisplayCache()
22+
visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs)
23+
24+
guard let tableColumnIndex = DataGridView.tableColumnIndex(
25+
for: columnIndex,
26+
in: tableView,
27+
schema: identitySchema
28+
) else { return }
29+
if case .cellChanged = delta {
30+
tableRowsController.apply(.cellChanged(row: row, column: tableColumnIndex))
31+
} else {
32+
tableView.reloadData(
33+
forRowIndexes: IndexSet(integer: row),
34+
columnIndexes: IndexSet(integer: tableColumnIndex)
35+
)
36+
}
37+
}
38+
39+
@discardableResult
40+
func recordCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) -> Delta? {
41+
cellCommitLogger.debug("recordCellEdit(row: \(row, privacy: .public), columnIndex: \(columnIndex, privacy: .public)) isCommitting=\(self.isCommittingCellEdit, privacy: .public) delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)")
42+
guard !isCommittingCellEdit else { return nil }
2143
let tableRows = tableRowsProvider()
22-
guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return }
23-
guard let displayRowValues = displayRow(at: row) else { return }
24-
guard columnIndex < displayRowValues.values.count else { return }
44+
guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return nil }
45+
guard let displayRowValues = displayRow(at: row) else { return nil }
46+
guard columnIndex < displayRowValues.values.count else { return nil }
2547
let oldValue = displayRowValues.values[columnIndex]
2648
guard oldValue != typedNewValue else {
27-
cellCommitLogger.debug("commitTypedCellEdit - value unchanged, guard returned")
28-
return
49+
cellCommitLogger.debug("recordCellEdit - value unchanged, guard returned")
50+
return nil
2951
}
3052

3153
isCommittingCellEdit = true
@@ -49,23 +71,8 @@ extension TableViewCoordinator {
4971
delta = tableRows.edit(row: storageRow, column: columnIndex, value: typedNewValue)
5072
}
5173
}
52-
cellCommitLogger.debug("commitTypedCellEdit - about to call delegate.dataGridDidEditCell, delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)")
74+
cellCommitLogger.debug("recordCellEdit - about to call delegate.dataGridDidEditCell, delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)")
5375
delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: typedNewValue.asText)
54-
invalidateDisplayCache()
55-
visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs)
56-
57-
guard let tableColumnIndex = DataGridView.tableColumnIndex(
58-
for: columnIndex,
59-
in: tableView,
60-
schema: identitySchema
61-
) else { return }
62-
if storageRow != nil, case .cellChanged = delta {
63-
tableRowsController.apply(.cellChanged(row: row, column: tableColumnIndex))
64-
} else {
65-
tableView.reloadData(
66-
forRowIndexes: IndexSet(integer: row),
67-
columnIndexes: IndexSet(integer: tableColumnIndex)
68-
)
69-
}
76+
return delta
7077
}
7178
}

TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,15 @@ extension TableViewCoordinator {
5858
undoManager?.beginUndoGrouping()
5959
undoManager?.setActionName(String(localized: "Fill Column"))
6060

61-
for row in targetRows {
62-
commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: value)
61+
var didEdit = false
62+
for row in targetRows where recordCellEdit(row: row, columnIndex: columnIndex, newValue: value) != nil {
63+
didEdit = true
6364
}
6465

6566
undoManager?.endUndoGrouping()
67+
68+
guard didEdit else { return }
69+
invalidateAllDisplayCaches()
6670
tableView?.reloadData()
6771
}
6872

TableProTests/Views/Results/Extensions/FillColumnTests.swift

Lines changed: 103 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
// FillColumnTests.swift
33
// TableProTests
44
//
5-
// Locks the Fill Column decision logic: which loaded rows a fill targets
6-
// (editable guard, deleted-row skipping, empty result) and how the dialog
7-
// resolves NULL distinctly from an empty string. The apply loop itself
8-
// reuses commitTypedCellEdit, which the cell-edit and paste paths exercise.
5+
// Locks Fill Column behaviour at two levels: the pure decisions (which loaded
6+
// rows a fill targets, how the dialog resolves NULL vs an empty string) and
7+
// the effect (applyFillColumn records one staged change per loaded row through
8+
// DataChangeManager, skipping deleted rows and read-only result sets).
99
//
1010

1111
import Foundation
@@ -14,11 +14,45 @@ import Testing
1414

1515
@testable import TablePro
1616

17+
@MainActor
18+
private final class NoopColumnLayoutPersister: ColumnLayoutPersisting {
19+
func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil }
20+
func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {}
21+
func clear(for tableName: String, connectionId: UUID) {}
22+
}
23+
1724
@Suite("Fill Column")
1825
@MainActor
1926
struct FillColumnTests {
20-
@Test("Fills every loaded row when editable and none are deleted")
21-
func fillsAllLoadedRows() {
27+
private func makeCoordinator(
28+
columns: [String],
29+
rowCount: Int,
30+
isEditable: Bool = true,
31+
manager: DataChangeManager
32+
) -> TableViewCoordinator {
33+
let coordinator = TableViewCoordinator(
34+
changeManager: AnyChangeManager(manager),
35+
isEditable: isEditable,
36+
selectedRowIndices: .constant([]),
37+
delegate: nil,
38+
layoutPersister: NoopColumnLayoutPersister()
39+
)
40+
let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count)
41+
let rows = (0..<rowCount).map { i in (0..<columns.count).map { c in "r\(i)c\(c)" } }
42+
let tableRows = TableRows.from(
43+
queryRows: rows.map { row in row.map { PluginCellValue.text($0) } },
44+
columns: columns,
45+
columnTypes: columnTypes
46+
)
47+
coordinator.tableRowsProvider = { tableRows }
48+
coordinator.updateCache()
49+
return coordinator
50+
}
51+
52+
// MARK: - Target selection
53+
54+
@Test("Targets every loaded row when editable and none are deleted")
55+
func targetsAllLoadedRows() {
2256
let rows = TableViewCoordinator.fillTargetRows(
2357
rowCount: 5,
2458
isEditable: true,
@@ -27,8 +61,8 @@ struct FillColumnTests {
2761
#expect(rows == [0, 1, 2, 3, 4])
2862
}
2963

30-
@Test("Skips rows marked for deletion")
31-
func skipsDeletedRows() {
64+
@Test("Excludes rows marked for deletion")
65+
func excludesDeletedRows() {
3266
let rows = TableViewCoordinator.fillTargetRows(
3367
rowCount: 5,
3468
isEditable: true,
@@ -57,6 +91,8 @@ struct FillColumnTests {
5791
#expect(rows.isEmpty)
5892
}
5993

94+
// MARK: - Value resolution
95+
6096
@Test("Resolves NULL distinctly from an empty string")
6197
func resolvesNullDistinctFromEmpty() {
6298
#expect(TableViewCoordinator.fillColumnValue(text: "ignored", setNull: true) == .null)
@@ -69,4 +105,63 @@ struct FillColumnTests {
69105
#expect(TableViewCoordinator.fillImpactDescription(rowCount: 1).contains("1 loaded row"))
70106
#expect(TableViewCoordinator.fillImpactDescription(rowCount: 42).contains("42 loaded rows"))
71107
}
108+
109+
// MARK: - Apply (staged effect)
110+
111+
@Test("Records one staged change per loaded row, leaving other columns untouched")
112+
func appliesToEveryLoadedRow() {
113+
let manager = DataChangeManager()
114+
let coordinator = makeCoordinator(columns: ["a", "b"], rowCount: 4, manager: manager)
115+
116+
coordinator.applyFillColumn(columnIndex: 0, value: .text("X"))
117+
118+
for row in 0..<4 {
119+
#expect(manager.pending.isCellModified(rowIndex: row, columnIndex: 0))
120+
}
121+
#expect(manager.pending.isCellModified(rowIndex: 0, columnIndex: 1) == false)
122+
}
123+
124+
@Test("Does not touch rows marked for deletion")
125+
func skipsDeletedRowsOnApply() {
126+
let manager = DataChangeManager()
127+
let coordinator = makeCoordinator(columns: ["a"], rowCount: 4, manager: manager)
128+
manager.recordRowDeletion(rowIndex: 2, originalRow: [.text("r2c0")])
129+
130+
coordinator.applyFillColumn(columnIndex: 0, value: .text("X"))
131+
132+
#expect(manager.pending.isCellModified(rowIndex: 0, columnIndex: 0))
133+
#expect(manager.pending.isCellModified(rowIndex: 2, columnIndex: 0) == false)
134+
}
135+
136+
@Test("Records nothing on a read-only result set")
137+
func recordsNothingWhenReadOnly() {
138+
let manager = DataChangeManager()
139+
let coordinator = makeCoordinator(columns: ["a"], rowCount: 4, isEditable: false, manager: manager)
140+
141+
coordinator.applyFillColumn(columnIndex: 0, value: .text("X"))
142+
143+
#expect(manager.hasChanges == false)
144+
}
145+
146+
@Test("Writes NULL as a null change, not an empty string")
147+
func appliesNull() {
148+
let manager = DataChangeManager()
149+
let coordinator = makeCoordinator(columns: ["a"], rowCount: 2, manager: manager)
150+
151+
coordinator.applyFillColumn(columnIndex: 0, value: .null)
152+
153+
let change = manager.pending.change(forRow: 0, type: .update)
154+
#expect(change?.cellChanges.first?.newValue == .null)
155+
}
156+
157+
@Test("Skips rows whose value already equals the fill value")
158+
func skipsRowsAlreadyEqual() {
159+
let manager = DataChangeManager()
160+
let coordinator = makeCoordinator(columns: ["a"], rowCount: 2, manager: manager)
161+
162+
coordinator.applyFillColumn(columnIndex: 0, value: .text("r0c0"))
163+
164+
#expect(manager.pending.isCellModified(rowIndex: 0, columnIndex: 0) == false)
165+
#expect(manager.pending.isCellModified(rowIndex: 1, columnIndex: 0))
166+
}
72167
}

0 commit comments

Comments
 (0)