From 1f749a6423b00394e3eacaf6272957a7c01b90af Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 15:40:48 +0700 Subject: [PATCH 1/2] feat(datagrid): fill column with one value across loaded rows (#1304) --- CHANGELOG.md | 4 + .../Extensions/DataGridView+FillColumn.swift | 129 ++++++++++++++++++ .../Extensions/DataGridView+Sort.swift | 14 ++ .../Results/Extensions/FillColumnTests.swift | 72 ++++++++++ docs/features/data-grid.mdx | 6 + 5 files changed, 225 insertions(+) create mode 100644 TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift create mode 100644 TableProTests/Views/Results/Extensions/FillColumnTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e862c382..4c0a5d639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304) + ## [0.44.0] - 2026-05-23 ### Added diff --git a/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift b/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift new file mode 100644 index 000000000..6aed1d697 --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift @@ -0,0 +1,129 @@ +// +// DataGridView+FillColumn.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +extension TableViewCoordinator { + @objc func fillColumn(_ sender: NSMenuItem) { + guard let columnIndex = sender.representedObject as? Int else { return } + presentFillColumnDialog(columnIndex: columnIndex) + } + + func presentFillColumnDialog(columnIndex: Int) { + guard isEditable, let window = tableView?.window else { return } + + let tableRows = tableRowsProvider() + guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } + + let rowCount = Self.fillTargetRows( + rowCount: cachedRowCount, + isEditable: isEditable, + isRowDeleted: changeManager.isRowDeleted + ).count + guard rowCount > 0 else { return } + + let columnName = tableRows.columns[columnIndex] + let allowsNull = tableRows.columnNullable[columnName] ?? true + let accessory = FillColumnAccessoryView(allowsNull: allowsNull) + + let alert = NSAlert() + alert.messageText = String(format: String(localized: "Fill column \"%@\""), columnName) + alert.informativeText = Self.fillImpactDescription(rowCount: rowCount) + alert.accessoryView = accessory + alert.addButton(withTitle: String(localized: "Fill")) + alert.addButton(withTitle: String(localized: "Cancel")) + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + self?.applyFillColumn(columnIndex: columnIndex, value: accessory.resolvedValue) + } + + DispatchQueue.main.async { + alert.window.makeFirstResponder(accessory.firstResponderView) + } + } + + func applyFillColumn(columnIndex: Int, value: PluginCellValue) { + let targetRows = Self.fillTargetRows( + rowCount: cachedRowCount, + isEditable: isEditable, + isRowDeleted: changeManager.isRowDeleted + ) + guard !targetRows.isEmpty else { return } + + let undoManager = tableView?.window?.undoManager + undoManager?.beginUndoGrouping() + undoManager?.setActionName(String(localized: "Fill Column")) + + for row in targetRows { + commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: value) + } + + undoManager?.endUndoGrouping() + tableView?.reloadData() + } + + static func fillTargetRows(rowCount: Int, isEditable: Bool, isRowDeleted: (Int) -> Bool) -> [Int] { + guard isEditable, rowCount > 0 else { return [] } + return (0.. PluginCellValue { + setNull ? .null : .text(text) + } + + static func fillImpactDescription(rowCount: Int) -> String { + if rowCount == 1 { + return String(localized: "This sets 1 loaded row. Review and Save to apply.") + } + return String( + format: String(localized: "This sets %lld loaded rows. Review and Save to apply."), + Int64(rowCount) + ) + } +} + +@MainActor +private final class FillColumnAccessoryView: NSView { + private let valueField = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + private let nullCheckbox: NSButton? + + init(allowsNull: Bool) { + nullCheckbox = allowsNull + ? NSButton(checkboxWithTitle: String(localized: "Set to NULL"), target: nil, action: nil) + : nil + super.init(frame: NSRect(x: 0, y: 0, width: 260, height: allowsNull ? 50 : 24)) + + valueField.usesSingleLineMode = true + valueField.placeholderString = String(localized: "Value") + valueField.frame = NSRect(x: 0, y: allowsNull ? 26 : 0, width: 260, height: 24) + addSubview(valueField) + + guard let nullCheckbox else { return } + nullCheckbox.frame = NSRect(x: 0, y: 0, width: 260, height: 18) + nullCheckbox.target = self + nullCheckbox.action = #selector(nullStateChanged) + addSubview(nullCheckbox) + } + + required init?(coder: NSCoder) { + nullCheckbox = nil + super.init(coder: coder) + } + + var resolvedValue: PluginCellValue { + TableViewCoordinator.fillColumnValue( + text: valueField.stringValue, + setNull: nullCheckbox?.state == .on + ) + } + + var firstResponderView: NSView { valueField } + + @objc private func nullStateChanged() { + valueField.isEnabled = nullCheckbox?.state != .on + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index f6d1c0caf..76706792e 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -102,6 +102,20 @@ extension TableViewCoordinator { menu.addItem(copyValuesItem) } + if let dataColumnIndex = dataColumnIndex(from: column.identifier), + isEditable, + cachedRowCount > 0, + !primaryKeyColumns.contains(baseName) { + let fillItem = NSMenuItem( + title: String(localized: "Fill Column…"), + action: #selector(fillColumn(_:)), + keyEquivalent: "" + ) + fillItem.representedObject = dataColumnIndex + fillItem.target = self + menu.addItem(fillItem) + } + let filterItem = NSMenuItem(title: String(localized: "Filter with column"), action: #selector(filterWithColumn(_:)), keyEquivalent: "") filterItem.representedObject = baseName filterItem.target = self diff --git a/TableProTests/Views/Results/Extensions/FillColumnTests.swift b/TableProTests/Views/Results/Extensions/FillColumnTests.swift new file mode 100644 index 000000000..2cd1741e5 --- /dev/null +++ b/TableProTests/Views/Results/Extensions/FillColumnTests.swift @@ -0,0 +1,72 @@ +// +// FillColumnTests.swift +// TableProTests +// +// Locks the Fill Column decision logic: which loaded rows a fill targets +// (editable guard, deleted-row skipping, empty result) and how the dialog +// resolves NULL distinctly from an empty string. The apply loop itself +// reuses commitTypedCellEdit, which the cell-edit and paste paths exercise. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Fill Column") +@MainActor +struct FillColumnTests { + @Test("Fills every loaded row when editable and none are deleted") + func fillsAllLoadedRows() { + let rows = TableViewCoordinator.fillTargetRows( + rowCount: 5, + isEditable: true, + isRowDeleted: { _ in false } + ) + #expect(rows == [0, 1, 2, 3, 4]) + } + + @Test("Skips rows marked for deletion") + func skipsDeletedRows() { + let rows = TableViewCoordinator.fillTargetRows( + rowCount: 5, + isEditable: true, + isRowDeleted: { $0 == 2 } + ) + #expect(rows == [0, 1, 3, 4]) + } + + @Test("Targets nothing on a read-only result set") + func noTargetsWhenReadOnly() { + let rows = TableViewCoordinator.fillTargetRows( + rowCount: 5, + isEditable: false, + isRowDeleted: { _ in false } + ) + #expect(rows.isEmpty) + } + + @Test("Targets nothing when no rows are loaded") + func noTargetsWhenEmpty() { + let rows = TableViewCoordinator.fillTargetRows( + rowCount: 0, + isEditable: true, + isRowDeleted: { _ in false } + ) + #expect(rows.isEmpty) + } + + @Test("Resolves NULL distinctly from an empty string") + func resolvesNullDistinctFromEmpty() { + #expect(TableViewCoordinator.fillColumnValue(text: "ignored", setNull: true) == .null) + #expect(TableViewCoordinator.fillColumnValue(text: "", setNull: false) == .text("")) + #expect(TableViewCoordinator.fillColumnValue(text: "active", setNull: false) == .text("active")) + } + + @Test("Impact description reflects singular and plural counts") + func impactDescriptionSingularAndPlural() { + #expect(TableViewCoordinator.fillImpactDescription(rowCount: 1).contains("1 loaded row")) + #expect(TableViewCoordinator.fillImpactDescription(rowCount: 42).contains("42 loaded rows")) + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index 740215491..50d3e6127 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -255,6 +255,12 @@ Select multiple rows with `Cmd+click` (non-contiguous) or `Shift+click` (range) /> +### Fill Column + +Right-click a column header and choose **Fill Column** to set one value across all loaded rows at once. Enter the value in the dialog, or check **Set to NULL** for nullable columns. The dialog shows how many rows it will set. + +Fill stages the change like any other edit, so review it with **Preview SQL** and **Commit** (`Cmd+S`) to apply, or **Discard** to drop it. `Cmd+Z` reverses the whole fill in one step. It only affects the rows currently loaded in the grid, not rows on other pages, and is not available on primary key columns. + ### Saving Changes Changes are queued, not applied immediately. Manage pending changes with: From 8b18b9aaac1a2ea55fe57571c784b4febee27b90 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 15:58:50 +0700 Subject: [PATCH 2/2] refactor(datagrid): decouple cell-edit model mutation for one-reload column fill (#1304) --- .../Extensions/DataGridView+CellCommit.swift | 55 +++++---- .../Extensions/DataGridView+FillColumn.swift | 8 +- .../Results/Extensions/FillColumnTests.swift | 111 ++++++++++++++++-- 3 files changed, 140 insertions(+), 34 deletions(-) diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index 2f4f80c07..08a74372b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -15,17 +15,39 @@ extension TableViewCoordinator { } func commitTypedCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) { - cellCommitLogger.debug("commitTypedCellEdit(row: \(row, privacy: .public), columnIndex: \(columnIndex, privacy: .public)) isCommitting=\(self.isCommittingCellEdit, privacy: .public) delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)") - guard !isCommittingCellEdit else { return } guard let tableView else { return } + guard let delta = recordCellEdit(row: row, columnIndex: columnIndex, newValue: typedNewValue) else { return } + + invalidateDisplayCache() + visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) + + guard let tableColumnIndex = DataGridView.tableColumnIndex( + for: columnIndex, + in: tableView, + schema: identitySchema + ) else { return } + if case .cellChanged = delta { + tableRowsController.apply(.cellChanged(row: row, column: tableColumnIndex)) + } else { + tableView.reloadData( + forRowIndexes: IndexSet(integer: row), + columnIndexes: IndexSet(integer: tableColumnIndex) + ) + } + } + + @discardableResult + func recordCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) -> Delta? { + cellCommitLogger.debug("recordCellEdit(row: \(row, privacy: .public), columnIndex: \(columnIndex, privacy: .public)) isCommitting=\(self.isCommittingCellEdit, privacy: .public) delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)") + guard !isCommittingCellEdit else { return nil } let tableRows = tableRowsProvider() - guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return } - guard let displayRowValues = displayRow(at: row) else { return } - guard columnIndex < displayRowValues.values.count else { return } + guard columnIndex >= 0 && columnIndex < tableRows.columns.count else { return nil } + guard let displayRowValues = displayRow(at: row) else { return nil } + guard columnIndex < displayRowValues.values.count else { return nil } let oldValue = displayRowValues.values[columnIndex] guard oldValue != typedNewValue else { - cellCommitLogger.debug("commitTypedCellEdit - value unchanged, guard returned") - return + cellCommitLogger.debug("recordCellEdit - value unchanged, guard returned") + return nil } isCommittingCellEdit = true @@ -49,23 +71,8 @@ extension TableViewCoordinator { delta = tableRows.edit(row: storageRow, column: columnIndex, value: typedNewValue) } } - cellCommitLogger.debug("commitTypedCellEdit - about to call delegate.dataGridDidEditCell, delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)") + cellCommitLogger.debug("recordCellEdit - about to call delegate.dataGridDidEditCell, delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)") delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: typedNewValue.asText) - invalidateDisplayCache() - visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) - - guard let tableColumnIndex = DataGridView.tableColumnIndex( - for: columnIndex, - in: tableView, - schema: identitySchema - ) else { return } - if storageRow != nil, case .cellChanged = delta { - tableRowsController.apply(.cellChanged(row: row, column: tableColumnIndex)) - } else { - tableView.reloadData( - forRowIndexes: IndexSet(integer: row), - columnIndexes: IndexSet(integer: tableColumnIndex) - ) - } + return delta } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift b/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift index 6aed1d697..3d8a01666 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+FillColumn.swift @@ -58,11 +58,15 @@ extension TableViewCoordinator { undoManager?.beginUndoGrouping() undoManager?.setActionName(String(localized: "Fill Column")) - for row in targetRows { - commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: value) + var didEdit = false + for row in targetRows where recordCellEdit(row: row, columnIndex: columnIndex, newValue: value) != nil { + didEdit = true } undoManager?.endUndoGrouping() + + guard didEdit else { return } + invalidateAllDisplayCaches() tableView?.reloadData() } diff --git a/TableProTests/Views/Results/Extensions/FillColumnTests.swift b/TableProTests/Views/Results/Extensions/FillColumnTests.swift index 2cd1741e5..1e7f43c8c 100644 --- a/TableProTests/Views/Results/Extensions/FillColumnTests.swift +++ b/TableProTests/Views/Results/Extensions/FillColumnTests.swift @@ -2,10 +2,10 @@ // FillColumnTests.swift // TableProTests // -// Locks the Fill Column decision logic: which loaded rows a fill targets -// (editable guard, deleted-row skipping, empty result) and how the dialog -// resolves NULL distinctly from an empty string. The apply loop itself -// reuses commitTypedCellEdit, which the cell-edit and paste paths exercise. +// Locks Fill Column behaviour at two levels: the pure decisions (which loaded +// rows a fill targets, how the dialog resolves NULL vs an empty string) and +// the effect (applyFillColumn records one staged change per loaded row through +// DataChangeManager, skipping deleted rows and read-only result sets). // import Foundation @@ -14,11 +14,45 @@ import Testing @testable import TablePro +@MainActor +private final class NoopColumnLayoutPersister: ColumnLayoutPersisting { + func load(for tableName: String, connectionId: UUID) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for tableName: String, connectionId: UUID) {} + func clear(for tableName: String, connectionId: UUID) {} +} + @Suite("Fill Column") @MainActor struct FillColumnTests { - @Test("Fills every loaded row when editable and none are deleted") - func fillsAllLoadedRows() { + private func makeCoordinator( + columns: [String], + rowCount: Int, + isEditable: Bool = true, + manager: DataChangeManager + ) -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(manager), + isEditable: isEditable, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: NoopColumnLayoutPersister() + ) + let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) + let rows = (0..