From 9a897db33a91a88196d8100a1c9b56d0175304ec Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 23 Jun 2026 00:11:34 +0700 Subject: [PATCH] feat(datagrid): add per-column value filter (#1454) --- CHANGELOG.md | 1 + TablePro/Models/Query/GridValueFilter.swift | 56 ++ .../Results/ColumnValueFilterPopover.swift | 197 +++++++ .../Views/Results/DataGridCoordinator.swift | 57 +- .../Results/DataGridUpdateSnapshot.swift | 1 + .../Results/DataGridView+RowActions.swift | 8 +- TablePro/Views/Results/DataGridView.swift | 10 +- .../Extensions/DataGridView+CellCommit.swift | 2 +- .../Extensions/DataGridView+Columns.swift | 2 +- .../Extensions/DataGridView+Sort.swift | 51 ++ .../Extensions/DataGridView+ValueFilter.swift | 140 +++++ .../Views/Results/SortableHeaderCell.swift | 96 +++- .../Views/Results/SortableHeaderView.swift | 63 ++- .../Views/Sidebar/NativeSearchField.swift | 3 +- .../Models/GridValueFilterStateTests.swift | 77 +++ .../Views/Results/RowVisualIndexTests.swift | 38 ++ ...TableViewCoordinatorValueFilterTests.swift | 198 +++++++ docs/features/data-grid.mdx | 493 ++++-------------- 18 files changed, 1049 insertions(+), 444 deletions(-) create mode 100644 TablePro/Models/Query/GridValueFilter.swift create mode 100644 TablePro/Views/Results/ColumnValueFilterPopover.swift create mode 100644 TablePro/Views/Results/Extensions/DataGridView+ValueFilter.swift create mode 100644 TableProTests/Models/GridValueFilterStateTests.swift create mode 100644 TableProTests/Views/Results/RowVisualIndexTests.swift create mode 100644 TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f92e9bb5..b519a369a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Connections can now have more than one tag. Assign several tags in the connection form, and filter the welcome list by tag with Match Any or Match All. (#744) +- Per-column value filter in the data grid. Hover a column header and click the funnel icon to pick which values to show from the loaded rows. Filter several columns at once, search the value list, and clear filters from the header menu. The filter runs on loaded rows without re-querying. (#1454) - Elasticsearch support. Connect to Elasticsearch 7.x and 8.x, browse indices, run Query DSL requests in a console, and edit documents in the data grid. Install from Settings > Plugins. (#1529) - The connection switcher and welcome list now show each connection's tags and group, so you can tell production from staging at a glance. (#1323) diff --git a/TablePro/Models/Query/GridValueFilter.swift b/TablePro/Models/Query/GridValueFilter.swift new file mode 100644 index 000000000..acbbf86bb --- /dev/null +++ b/TablePro/Models/Query/GridValueFilter.swift @@ -0,0 +1,56 @@ +// +// GridValueFilter.swift +// TablePro +// + +import Foundation + +struct ColumnValueFilter: Equatable { + var selectedValues: Set + var includesNull: Bool + + var hidesEverything: Bool { selectedValues.isEmpty && !includesNull } +} + +struct ColumnDistinctValue: Identifiable, Equatable { + let display: String + let isNull: Bool + let count: Int + + var id: String { isNull ? "\u{0}" : "v:\(display)" } +} + +struct GridValueFilterState: Equatable { + private(set) var filters: [Int: ColumnValueFilter] = [:] + private(set) var columnNames: [Int: String] = [:] + + var isActive: Bool { !filters.isEmpty } + var activeColumnCount: Int { filters.count } + var activeColumns: Set { Set(filters.keys) } + + func isActive(column dataIndex: Int) -> Bool { filters[dataIndex] != nil } + + func filter(forColumn dataIndex: Int) -> ColumnValueFilter? { filters[dataIndex] } + + mutating func set(_ filter: ColumnValueFilter, columnName: String, forColumn dataIndex: Int) { + filters[dataIndex] = filter + columnNames[dataIndex] = columnName + } + + mutating func clear(column dataIndex: Int) { + filters.removeValue(forKey: dataIndex) + columnNames.removeValue(forKey: dataIndex) + } + + mutating func clearAll() { + filters.removeAll() + columnNames.removeAll() + } + + mutating func prune(againstColumns columns: [String]) { + for (dataIndex, name) in columnNames where dataIndex >= columns.count || columns[dataIndex] != name { + filters.removeValue(forKey: dataIndex) + columnNames.removeValue(forKey: dataIndex) + } + } +} diff --git a/TablePro/Views/Results/ColumnValueFilterPopover.swift b/TablePro/Views/Results/ColumnValueFilterPopover.swift new file mode 100644 index 000000000..f08d51bf1 --- /dev/null +++ b/TablePro/Views/Results/ColumnValueFilterPopover.swift @@ -0,0 +1,197 @@ +// +// ColumnValueFilterPopover.swift +// TablePro +// + +import SwiftUI + +struct ColumnValueFilterPopover: View { + let columnName: String + let values: [ColumnDistinctValue] + let loadedRowCount: Int + let onApply: (ColumnValueFilter?) -> Void + let onCancel: () -> Void + + @State private var checkedValues: Set + @State private var nullChecked: Bool + @State private var searchText: String = "" + + private static let nullLabel = String(localized: "(NULL)") + private static let emptyLabel = String(localized: "(Empty)") + + init( + columnName: String, + values: [ColumnDistinctValue], + loadedRowCount: Int, + initialFilter: ColumnValueFilter?, + onApply: @escaping (ColumnValueFilter?) -> Void, + onCancel: @escaping () -> Void + ) { + self.columnName = columnName + self.values = values + self.loadedRowCount = loadedRowCount + self.onApply = onApply + self.onCancel = onCancel + if let initialFilter { + _checkedValues = State(initialValue: initialFilter.selectedValues) + _nullChecked = State(initialValue: initialFilter.includesNull) + } else { + _checkedValues = State(initialValue: Set(values.filter { !$0.isNull }.map(\.display))) + _nullChecked = State(initialValue: values.contains { $0.isNull }) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + controls + Divider() + valueList + Divider() + footer + } + .frame(width: 260) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 1) { + Text(columnName) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + Text(loadedRowsCaption) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 8) + } + + private var loadedRowsCaption: String { + String(format: String(localized: "Values from %d loaded rows"), loadedRowCount) + } + + private var controls: some View { + VStack(spacing: 8) { + NativeSearchField( + text: $searchText, + placeholder: String(localized: "Search values"), + onSubmit: { apply() }, + focusOnAppear: true, + accessibilityIdentifier: "value-filter-search" + ) + HStack(spacing: 6) { + TristateCheckbox(state: selectAllState) { toggleSelectAll() } + Text("Select All") + Spacer() + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + + private var valueList: some View { + List { + ForEach(filteredValues) { value in + Toggle(isOn: binding(for: value)) { + HStack(spacing: 8) { + Text(label(for: value)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(value.isNull ? Color.secondary : Color.primary) + Spacer(minLength: 8) + Text("\(value.count)") + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .frame(height: 200) + } + + private var footer: some View { + HStack { + Spacer() + Button(role: .cancel) { + onCancel() + } label: { + Text("Cancel") + } + .keyboardShortcut(.cancelAction) + Button { + apply() + } label: { + Text("Apply") + } + .keyboardShortcut(.defaultAction) + .disabled(nothingSelected) + } + .padding(14) + } + + private var filteredValues: [ColumnDistinctValue] { + guard !searchText.isEmpty else { return values } + return values.filter { label(for: $0).localizedCaseInsensitiveContains(searchText) } + } + + private var selectAllState: TristateCheckbox.State { + let selected = values.filter { $0.isNull ? nullChecked : checkedValues.contains($0.display) }.count + if selected == 0 { return .unchecked } + if selected == values.count { return .checked } + return .mixed + } + + private var nothingSelected: Bool { + checkedValues.isEmpty && !nullChecked + } + + private func label(for value: ColumnDistinctValue) -> String { + if value.isNull { return Self.nullLabel } + return value.display.isEmpty ? Self.emptyLabel : value.display + } + + private func binding(for value: ColumnDistinctValue) -> Binding { + if value.isNull { + return Binding(get: { nullChecked }, set: { nullChecked = $0 }) + } + return Binding( + get: { checkedValues.contains(value.display) }, + set: { isOn in + if isOn { + checkedValues.insert(value.display) + } else { + checkedValues.remove(value.display) + } + } + ) + } + + private func toggleSelectAll() { + setAll(selectAllState != .checked) + } + + private func setAll(_ selected: Bool) { + if selected { + checkedValues = Set(values.filter { !$0.isNull }.map(\.display)) + nullChecked = values.contains { $0.isNull } + } else { + checkedValues = [] + nullChecked = false + } + } + + private func apply() { + if selectAllState == .checked { + onApply(nil) + } else { + onApply(ColumnValueFilter(selectedValues: checkedValues, includesNull: nullChecked)) + } + } +} diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index ba61ea7d5..1b5230b13 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -18,10 +18,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData var changeManager: AnyChangeManager var isEditable: Bool var sortedIDs: [RowID]? + var valueFilteredIDs: [RowID]? + var valueFilterState = GridValueFilterState() + var displayIDs: [RowID]? { valueFilteredIDs ?? sortedIDs } private(set) var columnDisplayFormats: [ValueDisplayFormat?] = [] private let displayCache = RowDisplayCache() weak var delegate: (any DataGridViewDelegate)? weak var activeFKPreviewPopover: NSPopover? + weak var activeValueFilterPopover: NSPopover? var activeFKPreviewModel: FKPreviewModel? var activeFKPreviewColumnIndex: Int? var dropdownColumns: Set? @@ -247,6 +251,8 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData cachedColumnCount = 0 invalidateColumnIndexCache() sortedIDs = nil + valueFilteredIDs = nil + valueFilterState.clearAll() lastUpdateSnapshot = nil columnPool.detachFromTableView() if let tableView { @@ -257,12 +263,14 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } delegate = nil activeFKPreviewPopover?.close() + activeValueFilterPopover?.close() + activeValueFilterPopover = nil clearFKPreviewState() } func updateCache() { let tableRows = tableRowsProvider() - cachedRowCount = sortedIDs?.count ?? tableRows.count + cachedRowCount = displayIDs?.count ?? tableRows.count cachedColumnCount = tableRows.columns.count resizeRowNumberColumnForCurrentRange() } @@ -279,14 +287,22 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyInsertedRows(_ indices: IndexSet) { guard let tableView else { return } - visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs) + if valueFilterState.isActive { + reloadAfterRowMutationWithValueFilter() + return + } + visualIndex.rebuild(from: changeManager, sortedIDs: displayIDs) updateCache() tableView.insertRows(at: indices, withAnimation: .slideDown) } func applyRemovedRows(_ indices: IndexSet) { guard let tableView else { return } - visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs) + if valueFilterState.isActive { + reloadAfterRowMutationWithValueFilter() + return + } + visualIndex.rebuild(from: changeManager, sortedIDs: displayIDs) updateCache() tableView.removeRows(at: indices, withAnimation: .slideUp) } @@ -294,29 +310,40 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func applyFullReplace() { guard let tableView else { return } invalidateAllDisplayCaches() + recomputeValueFilteredIDs() + updateValueFilterHeaderIndicators() updateCache() selectionController.clear() tableView.reloadData() startBackgroundPrewarm() } + private func reloadAfterRowMutationWithValueFilter() { + guard let tableView else { return } + recomputeValueFilteredIDs() + updateCache() + visualIndex.rebuild(from: changeManager, sortedIDs: displayIDs) + tableView.reloadData() + startBackgroundPrewarm() + } + func displayRow(at displayIndex: Int) -> Row? { displayRow(at: displayIndex, in: tableRowsProvider()) } func displayRow(at displayIndex: Int, in tableRows: TableRows) -> Row? { - if let sorted = sortedIDs { - guard displayIndex >= 0, displayIndex < sorted.count else { return nil } - return tableRows.row(withID: sorted[displayIndex]) + 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] } func tableRowsIndex(forDisplayRow displayIndex: Int) -> Int? { - if let sorted = sortedIDs { - guard displayIndex >= 0, displayIndex < sorted.count else { return nil } - return tableRowsProvider().index(of: sorted[displayIndex]) + 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 } @@ -359,7 +386,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func invalidateAllDisplayCaches() { displayCache.removeAll() - visualIndex.rebuild(from: changeManager, sortedIDs: sortedIDs) + visualIndex.rebuild(from: changeManager, sortedIDs: displayIDs) } func updateDisplayFormats(_ formats: [ValueDisplayFormat?]) { @@ -375,7 +402,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData func preWarmDisplayCache(upTo rowCount: Int) { let tableRows = tableRowsProvider() - let displayCount = sortedIDs?.count ?? tableRows.count + let displayCount = displayIDs?.count ?? tableRows.count let count = min(rowCount, displayCount) guard count > 0 else { return } for displayIndex in 0..= 0, row < tableView.numberOfRows else { return } invalidateDisplayCache(forDisplayRow: row, column: column) - visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) + visualIndex.updateRow(row, from: changeManager, sortedIDs: displayIDs) tableView.reloadData( forRowIndexes: IndexSet(integer: row), columnIndexes: IndexSet(integer: tableColumn) @@ -523,7 +550,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData } guard !rowSet.isEmpty, !colSet.isEmpty else { return } for row in rowSet { - visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) + visualIndex.updateRow(row, from: changeManager, sortedIDs: displayIDs) } tableView.reloadData(forRowIndexes: rowSet, columnIndexes: colSet) case .rowsInserted(let indices): @@ -730,7 +757,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData // MARK: - NSTableViewDataSource func numberOfRows(in tableView: NSTableView) -> Int { - sortedIDs?.count ?? cachedRowCount + displayIDs?.count ?? cachedRowCount } } diff --git a/TablePro/Views/Results/DataGridUpdateSnapshot.swift b/TablePro/Views/Results/DataGridUpdateSnapshot.swift index 9540a68c8..575835904 100644 --- a/TablePro/Views/Results/DataGridUpdateSnapshot.swift +++ b/TablePro/Views/Results/DataGridUpdateSnapshot.swift @@ -12,6 +12,7 @@ struct DataGridUpdateSnapshot: Equatable { let columnCount: Int let columns: [String] let sortedIDsCount: Int? + let valueFilteredIDsCount: Int? let displayFormats: [ValueDisplayFormat?] let configuration: DataGridConfiguration let isEditable: Bool diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index 2aad46180..74563f887 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -15,7 +15,7 @@ extension TableViewCoordinator { @MainActor func undoDeleteRow(at index: Int) { changeManager.undoRowDeletion(rowIndex: index) - visualIndex.updateRow(index, from: changeManager, sortedIDs: sortedIDs) + visualIndex.updateRow(index, from: changeManager, sortedIDs: displayIDs) tableView?.reloadData( forRowIndexes: IndexSet(integer: index), columnIndexes: IndexSet(integersIn: 0..<(tableView?.numberOfColumns ?? 0))) @@ -216,7 +216,7 @@ extension TableViewCoordinator { func copyColumnValues(columnIndex: Int) { let tableRows = tableRowsProvider() guard columnIndex >= 0, columnIndex < tableRows.columns.count else { return } - let totalRows = sortedIDs?.count ?? tableRows.rows.count + let totalRows = displayIDs?.count ?? tableRows.rows.count let rowCount = min(totalRows, PluginRowLimits.emergencyMax) guard rowCount > 0 else { return } @@ -318,7 +318,7 @@ extension TableViewCoordinator { } func selectColumn(_ dataColumnIndex: Int) { - let totalRows = sortedIDs?.count ?? tableRowsProvider().rows.count + let totalRows = displayIDs?.count ?? tableRowsProvider().rows.count selectionController.selectEntireColumn(dataColumnIndex, totalRows: totalRows) if let keyTableView = tableView as? KeyHandlingTableView { keyTableView.deselectAll(nil) @@ -329,7 +329,7 @@ extension TableViewCoordinator { guard let rect = selection.boundingRectangle else { return } let tableRows = tableRowsProvider() let columnTypes = tableRows.columnTypes - let rowCount = sortedIDs?.count ?? tableRows.rows.count + let rowCount = displayIDs?.count ?? tableRows.rows.count let columnCount = tableRows.columns.count let rowRange = rect.rows.lowerBound...min(rect.rows.upperBound, max(0, rowCount - 1)) diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index ff43675f5..5ac88e547 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -148,7 +148,7 @@ struct DataGridView: NSViewRepresentable { coordinator.changeManager = changeManager let latestRows = tableRowsProvider() - let rowDisplayCount = sortedIDs?.count ?? latestRows.count + let rowDisplayCount = coordinator.valueFilteredIDs?.count ?? sortedIDs?.count ?? latestRows.count let columnCount = latestRows.columns.count let settings = AppSettingsManager.shared.dataGrid let rowHeight = CGFloat(settings.rowHeight.rawValue) @@ -159,6 +159,7 @@ struct DataGridView: NSViewRepresentable { columnCount: columnCount, columns: latestRows.columns, sortedIDsCount: sortedIDs?.count, + valueFilteredIDsCount: coordinator.valueFilteredIDs?.count, displayFormats: displayFormats, configuration: configuration, isEditable: isEditable, @@ -259,7 +260,9 @@ struct DataGridView: NSViewRepresentable { coordinator.primaryKeyColumns = configuration.primaryKeyColumns coordinator.tabType = configuration.tabType - coordinator.visualIndex.rebuild(from: coordinator.changeManager, sortedIDs: coordinator.sortedIDs) + coordinator.recomputeValueFilteredIDs() + coordinator.updateCache() + coordinator.visualIndex.rebuild(from: coordinator.changeManager, sortedIDs: coordinator.displayIDs) if !latestRows.columns.isEmpty { coordinator.isRebuildingColumns = true @@ -278,6 +281,8 @@ struct DataGridView: NSViewRepresentable { } } + coordinator.updateValueFilterHeaderIndicators() + if needsFullReload { coordinator.selectionController.clear() tableView.reloadData() @@ -365,6 +370,7 @@ struct DataGridView: NSViewRepresentable { let headerCell = SortableHeaderCell(textCell: "#") headerCell.font = defaultHeaderFont headerCell.alignment = .right + headerCell.supportsValueFilter = false headerCell.setAccessibilityLabel(String(localized: "Row number")) column.headerCell = headerCell return column diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index 254139960..799c9c0c6 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -19,7 +19,7 @@ extension TableViewCoordinator { guard let delta = recordCellEdit(row: row, columnIndex: columnIndex, newValue: typedNewValue) else { return } invalidateDisplayCache() - visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) + visualIndex.updateRow(row, from: changeManager, sortedIDs: displayIDs) guard let tableColumnIndex = tableColumnIndex(for: columnIndex) else { return } tableView.reloadData( diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index 43cc89baa..e196e2fc9 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -16,7 +16,7 @@ extension TableViewCoordinator { guard let column = tableColumn else { return nil } let tableRows = tableRowsProvider() - let displayCount = sortedIDs?.count ?? tableRows.count + let displayCount = displayIDs?.count ?? tableRows.count if column.identifier == ColumnIdentitySchema.rowNumberIdentifier { return cellRegistry.makeRowNumberCell( diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 76706792e..95a195c9f 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -121,6 +121,38 @@ extension TableViewCoordinator { filterItem.target = self menu.addItem(filterItem) + if let dataColumnIndex = dataColumnIndex(from: column.identifier) { + let filterValuesItem = NSMenuItem( + title: String(localized: "Filter Values…"), + action: #selector(filterColumnValues(_:)), + keyEquivalent: "" + ) + filterValuesItem.representedObject = dataColumnIndex + filterValuesItem.target = self + menu.addItem(filterValuesItem) + + if valueFilterState.isActive(column: dataColumnIndex) { + let clearColumnItem = NSMenuItem( + title: String(localized: "Clear Value Filter"), + action: #selector(clearColumnValueFilter(_:)), + keyEquivalent: "" + ) + clearColumnItem.representedObject = dataColumnIndex + clearColumnItem.target = self + menu.addItem(clearColumnItem) + } + } + + if valueFilterState.isActive { + let clearAllItem = NSMenuItem( + title: String(localized: "Clear All Value Filters"), + action: #selector(clearAllValueFiltersAction), + keyEquivalent: "" + ) + clearAllItem.target = self + menu.addItem(clearAllItem) + } + if let dataColumnIndex = dataColumnIndex(from: column.identifier) { let columnType = dataColumnIndex < tableRows.columnTypes.count ? tableRows.columnTypes[dataColumnIndex] : nil let applicableFormats = ValueDisplayFormat.applicableFormats(for: columnType) @@ -230,6 +262,25 @@ extension TableViewCoordinator { delegate?.dataGridFilterColumn(columnName) } + @objc func filterColumnValues(_ sender: NSMenuItem) { + guard let dataIndex = sender.representedObject as? Int, + let tableView, + let header = tableView.headerView as? SortableHeaderView, + let columnIndex = tableColumnIndex(for: dataIndex), + let cell = tableView.tableColumns[columnIndex].headerCell as? SortableHeaderCell else { return } + let anchor = cell.funnelRect(forBounds: header.headerRect(ofColumn: columnIndex)) + presentValueFilterPopover(forColumn: dataIndex, anchor: anchor, in: header) + } + + @objc func clearColumnValueFilter(_ sender: NSMenuItem) { + guard let dataIndex = sender.representedObject as? Int else { return } + applyValueFilter(nil, columnName: "", forColumn: dataIndex) + } + + @objc func clearAllValueFiltersAction() { + clearAllValueFilters() + } + @objc func hideColumn(_ sender: NSMenuItem) { guard let columnName = sender.representedObject as? String else { return } delegate?.dataGridHideColumn(columnName) diff --git a/TablePro/Views/Results/Extensions/DataGridView+ValueFilter.swift b/TablePro/Views/Results/Extensions/DataGridView+ValueFilter.swift new file mode 100644 index 000000000..9b04f5331 --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+ValueFilter.swift @@ -0,0 +1,140 @@ +// +// DataGridView+ValueFilter.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +extension TableViewCoordinator { + func distinctValues(forColumn dataIndex: Int) -> [ColumnDistinctValue] { + let tableRows = tableRowsProvider() + guard dataIndex >= 0, dataIndex < tableRows.columns.count else { return [] } + let columnType = dataIndex < tableRows.columnTypes.count ? tableRows.columnTypes[dataIndex] : nil + + var counts: [String: Int] = [:] + var order: [String] = [] + var nullCount = 0 + + for row in tableRows.rows { + guard dataIndex < row.values.count else { continue } + let rawValue = row.values[dataIndex] + if case .null = rawValue { + nullCount += 1 + continue + } + let display = displayValue(forID: row.id, column: dataIndex, rawValue: rawValue, columnType: columnType) + ?? rawValue.asText ?? "" + if counts[display] == nil { order.append(display) } + counts[display, default: 0] += 1 + } + + var result = order + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + .map { ColumnDistinctValue(display: $0, isNull: false, count: counts[$0] ?? 0) } + if nullCount > 0 { + result.insert(ColumnDistinctValue(display: "", isNull: true, count: nullCount), at: 0) + } + return result + } + + func recomputeValueFilteredIDs() { + let tableRows = tableRowsProvider() + valueFilterState.prune(againstColumns: tableRows.columns) + + guard valueFilterState.isActive else { + valueFilteredIDs = nil + return + } + + let baseOrder: [RowID] = sortedIDs ?? tableRows.rows.map(\.id) + var result: [RowID] = [] + result.reserveCapacity(baseOrder.count) + for id in baseOrder { + if id.isInserted { + result.append(id) + continue + } + guard let index = tableRows.index(of: id) else { continue } + if rowPassesValueFilter(tableRows.rows[index], in: tableRows) { + result.append(id) + } + } + valueFilteredIDs = result + } + + private func rowPassesValueFilter(_ row: Row, in tableRows: TableRows) -> Bool { + for (dataIndex, filter) in valueFilterState.filters { + guard dataIndex >= 0, dataIndex < row.values.count else { return false } + let rawValue = row.values[dataIndex] + if case .null = rawValue { + if !filter.includesNull { return false } + continue + } + let columnType = dataIndex < tableRows.columnTypes.count ? tableRows.columnTypes[dataIndex] : nil + let display = displayValue(forID: row.id, column: dataIndex, rawValue: rawValue, columnType: columnType) + ?? rawValue.asText ?? "" + if !filter.selectedValues.contains(display) { return false } + } + return true + } + + func applyValueFilter(_ filter: ColumnValueFilter?, columnName: String, forColumn dataIndex: Int) { + if let filter { + valueFilterState.set(filter, columnName: columnName, forColumn: dataIndex) + } else { + valueFilterState.clear(column: dataIndex) + } + reloadAfterValueFilterChange() + } + + func clearAllValueFilters() { + guard valueFilterState.isActive else { return } + valueFilterState.clearAll() + reloadAfterValueFilterChange() + } + + private func reloadAfterValueFilterChange() { + recomputeValueFilteredIDs() + updateCache() + visualIndex.rebuild(from: changeManager, sortedIDs: displayIDs) + selectionController.clear() + tableView?.reloadData() + updateValueFilterHeaderIndicators() + startBackgroundPrewarm() + } + + func updateValueFilterHeaderIndicators() { + guard let header = tableView?.headerView as? SortableHeaderView else { return } + header.updateValueFilterIndicators(activeColumns: valueFilterState.activeColumns) + } + + func presentValueFilterPopover(forColumn dataIndex: Int, anchor rect: NSRect, in view: NSView) { + let tableRows = tableRowsProvider() + guard dataIndex >= 0, dataIndex < tableRows.columns.count else { return } + let columnName = tableRows.columns[dataIndex] + let values = distinctValues(forColumn: dataIndex) + let initialFilter = valueFilterState.filter(forColumn: dataIndex) + let loadedRowCount = tableRows.count + + activeValueFilterPopover?.close() + activeValueFilterPopover = PopoverPresenter.show( + relativeTo: rect, + of: view, + preferredEdge: .maxY + ) { [weak self] dismiss in + ColumnValueFilterPopover( + columnName: columnName, + values: values, + loadedRowCount: loadedRowCount, + initialFilter: initialFilter, + onApply: { filter in + self?.applyValueFilter(filter, columnName: columnName, forColumn: dataIndex) + dismiss() + }, + onCancel: dismiss + ) + } + } +} diff --git a/TablePro/Views/Results/SortableHeaderCell.swift b/TablePro/Views/Results/SortableHeaderCell.swift index 30598be21..6998850c0 100644 --- a/TablePro/Views/Results/SortableHeaderCell.swift +++ b/TablePro/Views/Results/SortableHeaderCell.swift @@ -10,11 +10,16 @@ final class SortableHeaderCell: NSTableHeaderCell { var sortDirection: SortDirection? var sortPriority: Int? var isColumnSelected: Bool = false + var isValueFiltered: Bool = false + var isFunnelVisible: Bool = false + var supportsValueFilter: Bool = true private static let indicatorPadding: CGFloat = 4 private static let indicatorSpacing: CGFloat = 2 private static let priorityFontSize: CGFloat = 9 private static let defaultIndicatorSize = NSSize(width: 9, height: 6) + private static let funnelSize = NSSize(width: 13, height: 13) + private static let funnelPointSize: CGFloat = 11 override init(textCell string: String) { super.init(textCell: string) @@ -43,11 +48,31 @@ final class SortableHeaderCell: NSTableHeaderCell { color: foreground ) + var trailingCursorX = cellFrame.maxX - Self.indicatorPadding + + if supportsValueFilter { + if isValueFiltered || isFunnelVisible { + let funnelImage = Self.funnelImage( + active: isValueFiltered, + color: funnelColor(active: isValueFiltered, emphasized: isColumnSelected) + ) + let drawSize = funnelImage?.size ?? Self.funnelSize + let funnelRect = NSRect( + x: trailingCursorX - drawSize.width, + y: cellFrame.midY - drawSize.height / 2, + width: drawSize.width, + height: drawSize.height + ) + Self.drawIndicator(image: funnelImage, in: funnelRect) + } + trailingCursorX -= Self.funnelSize.width + Self.indicatorSpacing + } + guard let direction = sortDirection else { return } let indicatorImage = Self.indicatorImage(for: direction, color: foreground) let indicatorSize = indicatorImage?.size ?? Self.defaultIndicatorSize - let indicatorOriginX = cellFrame.maxX - Self.indicatorPadding - indicatorSize.width + let indicatorOriginX = trailingCursorX - indicatorSize.width let indicatorOriginY = cellFrame.midY - indicatorSize.height / 2 let indicatorRect = NSRect( x: indicatorOriginX, @@ -70,6 +95,17 @@ final class SortableHeaderCell: NSTableHeaderCell { } } + func funnelRect(forBounds rect: NSRect) -> NSRect { + guard supportsValueFilter else { return .null } + let size = Self.funnelSize + return NSRect( + x: rect.maxX - Self.indicatorPadding - size.width, + y: rect.midY - size.height / 2, + width: size.width, + height: size.height + ) + } + override func titleRect(forBounds rect: NSRect) -> NSRect { let inset = min(DataGridMetrics.cellHorizontalInset, rect.width / 2) let availableWidth = max(0, rect.width - inset * 2 - reservedTrailingWidth()) @@ -82,12 +118,32 @@ final class SortableHeaderCell: NSTableHeaderCell { } private func reservedTrailingWidth() -> CGFloat { - guard let direction = sortDirection else { return 0 } - let indicatorWidth = Self.indicatorImage(for: direction, color: .secondaryLabelColor)?.size.width - ?? Self.defaultIndicatorSize.width - let priorityText = priorityNumberString() - let priorityComponent = priorityText.map { Self.measureWidth(of: $0, color: .secondaryLabelColor) + Self.indicatorSpacing } ?? 0 - return indicatorWidth + Self.indicatorPadding * 2 + priorityComponent + var width: CGFloat = 0 + if supportsValueFilter { + width += Self.funnelSize.width + Self.indicatorSpacing + } + if let direction = sortDirection { + width += Self.indicatorImage(for: direction, color: .secondaryLabelColor)?.size.width + ?? Self.defaultIndicatorSize.width + if let priorityText = priorityNumberString() { + width += Self.measureWidth(of: priorityText, color: .secondaryLabelColor) + Self.indicatorSpacing + } + } + guard width > 0 else { return 0 } + return width + Self.indicatorPadding * 2 + } + + private func funnelColor(active: Bool, emphasized: Bool) -> NSColor { + if emphasized { return .alternateSelectedControlTextColor } + return active ? .controlAccentColor : .secondaryLabelColor + } + + private static func funnelImage(active: Bool, color: NSColor) -> NSImage? { + let symbolName = active ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle" + let configuration = NSImage.SymbolConfiguration(pointSize: funnelPointSize, weight: .regular) + .applying(.init(hierarchicalColor: color)) + return NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) } private func titleFont(isSorted: Bool) -> NSFont { @@ -130,20 +186,22 @@ final class SortableHeaderCell: NSTableHeaderCell { ) {} override func accessibilityLabel() -> String? { - let baseLabel = super.accessibilityLabel() ?? stringValue - guard let direction = sortDirection else { return baseLabel } - let directionSuffix: String - switch direction { - case .ascending: - directionSuffix = String(localized: "Sorted ascending") - case .descending: - directionSuffix = String(localized: "Sorted descending") + var components = [super.accessibilityLabel() ?? stringValue] + if let direction = sortDirection { + switch direction { + case .ascending: + components.append(String(localized: "Sorted ascending")) + case .descending: + components.append(String(localized: "Sorted descending")) + } + if let sortPriority, sortPriority >= 2 { + components.append(String(format: String(localized: "Priority %d"), sortPriority)) + } } - guard let sortPriority, sortPriority >= 2 else { - return "\(baseLabel), \(directionSuffix)" + if isValueFiltered { + components.append(String(localized: "Filtered")) } - let prioritySuffix = String(format: String(localized: "Priority %d"), sortPriority) - return "\(baseLabel), \(directionSuffix), \(prioritySuffix)" + return components.joined(separator: ", ") } private func priorityNumberString() -> String? { diff --git a/TablePro/Views/Results/SortableHeaderView.swift b/TablePro/Views/Results/SortableHeaderView.swift index 2176dd6b0..43c879b26 100644 --- a/TablePro/Views/Results/SortableHeaderView.swift +++ b/TablePro/Views/Results/SortableHeaderView.swift @@ -69,6 +69,7 @@ final class SortableHeaderView: NSTableHeaderView { private var pendingClickStartLocation: NSPoint? private var dragOccurredDuringClick = false private var mouseMovedTrackingArea: NSTrackingArea? + private var hoveredColumnIndex: Int? override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -85,7 +86,7 @@ final class SortableHeaderView: NSTableHeaderView { } let area = NSTrackingArea( rect: bounds, - options: [.activeInKeyWindow, .mouseMoved, .inVisibleRect], + options: [.activeInKeyWindow, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil ) @@ -107,8 +108,52 @@ final class SortableHeaderView: NSTableHeaderView { } if inResizeZone { NSCursor.resizeLeftRight.set() + updateFunnelHover(column: nil) } else { NSCursor.arrow.set() + updateFunnelHover(column: hoverableColumn(at: point)) + } + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + updateFunnelHover(column: nil) + } + + private func hoverableColumn(at point: NSPoint) -> Int? { + guard let tableView else { return nil } + let columnIndex = column(at: point) + guard columnIndex >= 0, columnIndex < tableView.numberOfColumns else { return nil } + guard tableView.tableColumns[columnIndex].identifier != ColumnIdentitySchema.rowNumberIdentifier else { return nil } + return columnIndex + } + + private func updateFunnelHover(column columnIndex: Int?) { + guard hoveredColumnIndex != columnIndex else { return } + let previous = hoveredColumnIndex + hoveredColumnIndex = columnIndex + guard let tableView else { return } + for index in [previous, columnIndex].compactMap({ $0 }) { + guard index >= 0, index < tableView.tableColumns.count, + let cell = tableView.tableColumns[index].headerCell as? SortableHeaderCell else { continue } + let shouldShow = index == columnIndex + if cell.isFunnelVisible != shouldShow { + cell.isFunnelVisible = shouldShow + setNeedsDisplay(headerRect(ofColumn: index)) + } + } + } + + func updateValueFilterIndicators(activeColumns: Set) { + guard let tableView, let coordinator else { return } + for (columnIndex, column) in tableView.tableColumns.enumerated() { + guard let cell = column.headerCell as? SortableHeaderCell, + let dataIndex = coordinator.dataColumnIndex(from: column.identifier) else { continue } + let shouldBeFiltered = activeColumns.contains(dataIndex) + if cell.isValueFiltered != shouldBeFiltered { + cell.isValueFiltered = shouldBeFiltered + setNeedsDisplay(headerRect(ofColumn: columnIndex)) + } } } @@ -181,6 +226,16 @@ final class SortableHeaderView: NSTableHeaderView { return } + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if modifierFlags.isEmpty, + let cell = column.headerCell as? SortableHeaderCell { + let funnelRect = cell.funnelRect(forBounds: headerRect(ofColumn: columnIndex)) + if funnelRect.insetBy(dx: -2, dy: -4).contains(pointInHeader) { + coordinator.presentValueFilterPopover(forColumn: dataIndex, anchor: funnelRect, in: self) + return + } + } + let originalColumnOrder = tableView.tableColumns.map { $0.identifier } let originalColumnWidths = tableView.tableColumns.map { $0.width } pendingClickStartLocation = pointInHeader @@ -198,14 +253,12 @@ final class SortableHeaderView: NSTableHeaderView { return } - let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - - if modifiers.contains(.command) && !modifiers.contains(.shift) { + if modifierFlags.contains(.command) && !modifierFlags.contains(.shift) { coordinator.selectColumn(dataIndex) return } - let isMultiSort = modifiers.contains(.shift) + let isMultiSort = modifierFlags.contains(.shift) let transition = HeaderSortCycle.nextTransition( state: coordinator.currentSortState, clickedColumn: dataIndex, diff --git a/TablePro/Views/Sidebar/NativeSearchField.swift b/TablePro/Views/Sidebar/NativeSearchField.swift index 13d2a57f4..aa50e4953 100644 --- a/TablePro/Views/Sidebar/NativeSearchField.swift +++ b/TablePro/Views/Sidebar/NativeSearchField.swift @@ -61,6 +61,7 @@ struct NativeSearchField: NSViewRepresentable { var focusOnAppear: Bool = false var focusTrigger: Int = 0 var maxWidth: CGFloat? + var accessibilityIdentifier: String = "sidebar-filter" func makeNSView(context: Context) -> NSSearchField { let field = IntrinsicHeightSearchField() @@ -68,7 +69,7 @@ struct NativeSearchField: NSViewRepresentable { field.delegate = context.coordinator field.controlSize = controlSize field.sendsSearchStringImmediately = true - field.setAccessibilityIdentifier("sidebar-filter") + field.setAccessibilityIdentifier(accessibilityIdentifier) field.cell?.usesSingleLineMode = true if let maxWidth { field.preferredMaxLayoutWidth = maxWidth diff --git a/TableProTests/Models/GridValueFilterStateTests.swift b/TableProTests/Models/GridValueFilterStateTests.swift new file mode 100644 index 000000000..394c934eb --- /dev/null +++ b/TableProTests/Models/GridValueFilterStateTests.swift @@ -0,0 +1,77 @@ +// +// GridValueFilterStateTests.swift +// TableProTests +// + +import Testing + +@testable import TablePro + +@Suite("GridValueFilterState") +struct GridValueFilterStateTests { + @Test("set marks a column active") + func setMarksColumnActive() { + var state = GridValueFilterState() + state.set(ColumnValueFilter(selectedValues: ["a"], includesNull: false), columnName: "status", forColumn: 0) + + #expect(state.isActive) + #expect(state.isActive(column: 0)) + #expect(state.activeColumnCount == 1) + #expect(state.activeColumns == [0]) + #expect(state.filter(forColumn: 0)?.selectedValues == ["a"]) + } + + @Test("clear removes a single column") + func clearRemovesColumn() { + var state = GridValueFilterState() + state.set(ColumnValueFilter(selectedValues: ["a"], includesNull: false), columnName: "status", forColumn: 0) + state.set(ColumnValueFilter(selectedValues: ["b"], includesNull: false), columnName: "name", forColumn: 1) + + state.clear(column: 0) + + #expect(!state.isActive(column: 0)) + #expect(state.isActive(column: 1)) + #expect(state.activeColumnCount == 1) + } + + @Test("clearAll empties the state") + func clearAllEmptiesState() { + var state = GridValueFilterState() + state.set(ColumnValueFilter(selectedValues: ["a"], includesNull: false), columnName: "status", forColumn: 0) + state.set(ColumnValueFilter(selectedValues: ["b"], includesNull: false), columnName: "name", forColumn: 1) + + state.clearAll() + + #expect(!state.isActive) + #expect(state.activeColumnCount == 0) + } + + @Test("prune drops filters whose column name changed") + func pruneDropsRenamedColumn() { + var state = GridValueFilterState() + state.set(ColumnValueFilter(selectedValues: ["a"], includesNull: false), columnName: "status", forColumn: 0) + state.set(ColumnValueFilter(selectedValues: ["b"], includesNull: false), columnName: "name", forColumn: 1) + + state.prune(againstColumns: ["status", "email"]) + + #expect(state.isActive(column: 0)) + #expect(!state.isActive(column: 1)) + } + + @Test("prune drops filters past the column count") + func pruneDropsOutOfRangeColumn() { + var state = GridValueFilterState() + state.set(ColumnValueFilter(selectedValues: ["a"], includesNull: false), columnName: "status", forColumn: 2) + + state.prune(againstColumns: ["status", "name"]) + + #expect(!state.isActive) + } + + @Test("hidesEverything reflects an empty selection") + func hidesEverythingWhenNothingSelected() { + #expect(ColumnValueFilter(selectedValues: [], includesNull: false).hidesEverything) + #expect(!ColumnValueFilter(selectedValues: [], includesNull: true).hidesEverything) + #expect(!ColumnValueFilter(selectedValues: ["a"], includesNull: false).hidesEverything) + } +} diff --git a/TableProTests/Views/Results/RowVisualIndexTests.swift b/TableProTests/Views/Results/RowVisualIndexTests.swift new file mode 100644 index 000000000..1a2ab8fbe --- /dev/null +++ b/TableProTests/Views/Results/RowVisualIndexTests.swift @@ -0,0 +1,38 @@ +// +// RowVisualIndexTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("RowVisualIndex display-order mapping") +@MainActor +struct RowVisualIndexTests { + @Test("an inserted row is marked at its display index, not its model index") + func insertedRowMarkedAtDisplayIndex() { + let index = RowVisualIndex() + let changeManager = AnyChangeManager(DataChangeManager()) + let displayIDs: [RowID] = [.existing(0), .inserted(UUID()), .existing(2)] + + index.rebuild(from: changeManager, sortedIDs: displayIDs) + + #expect(index.visualState(for: 1).isInserted) + #expect(!index.visualState(for: 0).isInserted) + #expect(!index.visualState(for: 2).isInserted) + } + + @Test("no inserted rows leaves every display index clean") + func noInsertedRowsLeavesStateEmpty() { + let index = RowVisualIndex() + let changeManager = AnyChangeManager(DataChangeManager()) + let displayIDs: [RowID] = [.existing(0), .existing(1)] + + index.rebuild(from: changeManager, sortedIDs: displayIDs) + + #expect(index.visualState(for: 0) == .empty) + #expect(index.visualState(for: 1) == .empty) + } +} diff --git a/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift b/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift new file mode 100644 index 000000000..9b17a404d --- /dev/null +++ b/TableProTests/Views/Results/TableViewCoordinatorValueFilterTests.swift @@ -0,0 +1,198 @@ +// +// TableViewCoordinatorValueFilterTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("TableViewCoordinator value filter") +@MainActor +struct TableViewCoordinatorValueFilterTests { + private func makeCoordinator() -> TableViewCoordinator { + let coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: nil, + layoutPersister: FakeValueFilterPersister() + ) + 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")]), + ] + var captured = TableRows( + rows: rows, + columns: ["status", "name"], + columnTypes: [.text(rawType: nil), .text(rawType: nil)] + ) + coordinator.tableRowsProvider = { captured } + coordinator.tableRowsMutator = { mutation in mutation(&captured) } + coordinator.updateCache() + return coordinator + } + + @Test("distinctValues groups values with counts and a null bucket") + func distinctValuesGroupsWithCounts() { + let coordinator = makeCoordinator() + let values = coordinator.distinctValues(forColumn: 0) + + #expect(values.count == 3) + #expect(values.first?.isNull == true) + #expect(values.first?.count == 1) + let active = values.first { $0.display == "active" } + let inactive = values.first { $0.display == "inactive" } + #expect(active?.count == 2) + #expect(inactive?.count == 1) + } + + @Test("applying a value filter narrows the display set") + func applyingFilterNarrowsRows() { + let coordinator = makeCoordinator() + + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["active"], includesNull: false), + columnName: "status", + forColumn: 0 + ) + + #expect(coordinator.valueFilteredIDs == [.existing(0), .existing(2)]) + #expect(coordinator.cachedRowCount == 2) + #expect(coordinator.displayRow(at: 0)?.id == .existing(0)) + #expect(coordinator.displayRow(at: 1)?.id == .existing(2)) + #expect(coordinator.tableRowsIndex(forDisplayRow: 1) == 2) + } + + @Test("numberOfRows serves the filtered count") + func numberOfRowsServesFilteredCount() { + let coordinator = makeCoordinator() + let tableView = NSTableView() + + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["active"], includesNull: false), + columnName: "status", + forColumn: 0 + ) + + #expect(coordinator.numberOfRows(in: tableView) == 2) + } + + @Test("multiple column filters intersect") + func multipleColumnFiltersIntersect() { + let coordinator = makeCoordinator() + + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["active"], includesNull: false), + columnName: "status", + forColumn: 0 + ) + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["c"], includesNull: false), + columnName: "name", + forColumn: 1 + ) + + #expect(coordinator.valueFilteredIDs == [.existing(2)]) + #expect(coordinator.cachedRowCount == 1) + } + + @Test("null selection matches only null rows") + func nullSelectionMatchesNullRows() { + let coordinator = makeCoordinator() + + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: [], includesNull: true), + columnName: "status", + forColumn: 0 + ) + + #expect(coordinator.valueFilteredIDs == [.existing(3)]) + } + + @Test("clearing one filter recomputes the remaining intersection") + func clearingOneFilterRecomputes() { + let coordinator = makeCoordinator() + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["active"], includesNull: false), + columnName: "status", + forColumn: 0 + ) + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["c"], includesNull: false), + columnName: "name", + forColumn: 1 + ) + + coordinator.applyValueFilter(nil, columnName: "name", forColumn: 1) + + #expect(coordinator.valueFilteredIDs == [.existing(0), .existing(2)]) + } + + @Test("clearAllValueFilters restores every loaded row") + func clearAllRestoresRows() { + let coordinator = makeCoordinator() + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["active"], includesNull: false), + columnName: "status", + forColumn: 0 + ) + + coordinator.clearAllValueFilters() + + #expect(coordinator.valueFilteredIDs == nil) + #expect(coordinator.cachedRowCount == 4) + } + + @Test("inserted rows stay visible while a filter is active") + func insertedRowsStayVisible() { + let coordinator = makeCoordinator() + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["active"], includesNull: false), + columnName: "status", + forColumn: 0 + ) + + coordinator.tableRowsMutator { rows in + _ = rows.appendInsertedRow(values: [.text("inactive"), .text("z")]) + } + coordinator.recomputeValueFilteredIDs() + coordinator.updateCache() + + #expect(coordinator.valueFilteredIDs?.count == 3) + #expect(coordinator.valueFilteredIDs?.last?.isInserted == true) + } + + @Test("a column set change prunes stale filters on full replace") + func fullReplacePrunesStaleFilters() { + let coordinator = makeCoordinator() + coordinator.applyValueFilter( + ColumnValueFilter(selectedValues: ["active"], includesNull: false), + columnName: "status", + forColumn: 0 + ) + + coordinator.tableRowsMutator { rows in + rows.columns = ["other", "name"] + } + coordinator.recomputeValueFilteredIDs() + coordinator.updateCache() + + #expect(coordinator.valueFilteredIDs == nil) + #expect(!coordinator.valueFilterState.isActive) + } +} + +@MainActor +private final class FakeValueFilterPersister: 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) {} +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index ad9680062..8fc291259 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -1,465 +1,166 @@ --- title: Data Grid -description: Spreadsheet-style grid with inline editing, type-specific editors, and copy in TSV/CSV/JSON formats +description: Sort, filter, edit, and copy query results in a spreadsheet-style grid --- # Data Grid -Query results and table contents render in a spreadsheet-style grid. Edit cells inline, sort columns, copy in multiple formats, and paginate through large result sets. - -{/* Screenshot: Data grid showing query results with multiple columns */} - - Data Grid - Data Grid - - -## Viewing Data - -The header shows row count, execution time, and affected rows for UPDATE/DELETE queries. - -### View Modes - -Toggle between **Data**, **Structure**, and **JSON** in the status bar. Query tabs show Data and JSON only. +Query results and table data open in a spreadsheet-style grid. Sort and filter columns, edit cells inline, and copy in several formats. -JSON mode renders all rows as a JSON array. Toggle between Text and Tree views in the toolbar. To export only specific rows, select them in Data mode first, then switch to JSON. The Copy JSON button writes to the clipboard. View mode is remembered per tab. - -### Foreign Key Navigation - -Foreign key cells show an arrow on the right edge. Click it to open the referenced table, filtered to the matching row. A plain click uses the current tab; `Cmd`-click opens a new tab. - -Right-click the cell for the same options plus **Preview Referenced Row**, which shows the row in a popover. + + Data grid + Data grid + -## Column Features +## View Modes -### Resizing Columns +Switch between **Data**, **Structure**, and **JSON** in the status bar. Query tabs show Data and JSON only. The mode is remembered per tab. -- Drag column borders to resize -- Double-click a border to auto-fit column width -- Column widths are remembered per table +JSON mode shows the rows as a JSON array, with **Text** and **Tree** views. Select rows in Data mode first to limit what JSON shows, then use **Copy JSON**. -### Sorting Data +## Columns -Click a column header to sort: +### Sort -- **First click**: Sort ascending (A-Z, 0-9) -- **Second click**: Sort descending (Z-A, 9-0) -- **Third click**: Remove sort +Click a header to cycle through ascending, descending, and off. Sort re-runs the query with `ORDER BY` appended, replacing any existing one. -By default tables open in engine order (no `ORDER BY`). To always sort by primary key or first column on open, set Settings > Data Grid > Default row sort. Options are **No sorting** (default), **Primary key**, and **First column**. Tables without a primary key fall back to engine order even when Primary key is selected. Click a column header at any time to replace the default sort. +To sort on open, set **Settings > Data Grid > Default row sort** to **Primary key** or **First column** (default is **No sorting**). A click on any header still overrides it. -If the resolved sort column is not orderable on the server (for example a `BLOB`, `JSON`, or spatial column with First column selected), the query fails. Switch the setting back to No sorting, or click a different column header. +If the sort column can't be ordered on the server (a `BLOB`, `JSON`, or spatial column), the query fails. Pick another column or set the default back to No sorting. -Sort applies to the full result. TablePro re-runs the query with `ORDER BY` appended; if your query already has an `ORDER BY`, it is replaced. - -{/* Screenshot: Column header with sort indicator */} - - Column header with sort indicator - Column header with sort indicator - - -### Hiding Columns - -Toggle columns on or off from the columns button in the status bar, or a column header's right-click menu. Hidden columns are remembered per table. - -A hidden column is also left out of the query, so it isn't fetched. If a table is slow to open because one column holds a lot of data, hide that column and the table loads faster. Showing it again re-runs the query to load it. The primary key is always fetched, so editing and saving keep working even if you hide it. - - -## Data Editing - -### Inline Editing - -To edit a cell: - -1. Double-click the cell -2. Enter the new value -3. Press `Enter` to confirm or `Escape` to cancel +### Filter by Value -{/* Screenshot: Cell being edited with cursor */} - - Cell editing - Cell editing - - - -### Date/Time Picker - -Date, datetime, timestamp, and time columns have a date picker. Double-click the cell to edit the value as text, or click the chevron button to open the picker, which shows a calendar for the date and hour, minute, and second fields for the time. `DATE` shows the calendar only; `TIME` shows the time fields only; `DATETIME` and `TIMESTAMP` show both. The picker keeps the value's existing format, fractional seconds, and timezone offset. An empty or unparseable cell starts at the current date and time. To set the current timestamp or `NULL`, right-click the cell and use Set Value. - -{/* Screenshot: Date picker for editing date columns */} - - Date picker for editing date columns - Date picker for editing date columns - - -### Foreign Key Lookup - -Foreign key columns open a searchable dropdown with values from the referenced table (e.g., `1 - John Doe`). Type to filter, double-click or press `Enter` to commit. Fetches up to 1,000 values; use the search field to narrow larger sets. - -{/* Screenshot: Foreign key lookup with search */} - - Foreign key lookup with search - Foreign key lookup with search - - -### Boolean Cell Editor - -BOOLEAN, BIT, and TINYINT(1) cells open a checkbox popover. Click to toggle, `Enter` to commit, `Escape` to cancel. Nullable columns support a third indeterminate state (NULL). - -| Database | Column Types | Values | -|----------|-------------|--------| -| MySQL/MariaDB | `TINYINT(1)`, `BIT(1)` | `0` / `1` | -| PostgreSQL | `BOOLEAN` | `TRUE` / `FALSE` | -| SQLite | `INTEGER` | `0` / `1` | - -{/* Screenshot: Boolean cell checkbox editor */} - - Boolean cell checkbox editor - Boolean cell checkbox editor - - -### ENUM Column Editor - -ENUM cells open a searchable dropdown. Click a value to select and commit. Nullable columns show a NULL option at the top. - -| Database | ENUM Source | -|----------|------------| -| MySQL/MariaDB | Native `ENUM` type | -| PostgreSQL | User-defined enum types (`pg_enum`) | -| SQLite | `CHECK(column IN (...))` constraints | - -{/* Screenshot: ENUM value selector */} - - ENUM value selector - ENUM value selector - - -### SET Column Editor - -SET cells (MySQL/MariaDB) open a checkbox popover. Check/uncheck values, then press `Enter` to commit or `Escape` to cancel. - - -If ENUM/SET metadata is unavailable (e.g., from a complex query), the cell falls back to inline text editing. - - -{/* Screenshot: SET value multi-select editor */} - - SET value multi-select editor - SET value multi-select editor - +Filter the loaded rows by picking values, without re-querying. Hover a header and click the funnel icon, or right-click the header and choose **Filter Values…**. -### JSON Viewer +The popover lists each value in the column with its count. Search to narrow the list, check the values to keep, and click **Apply**. NULL and empty values appear as their own entries. `Return` applies, `Escape` cancels. -Click the chevron in a JSON cell to open the viewer. Double-click or `Enter` edits the value inline instead. Switch between **Text** and **Tree** modes with the toggle at the top. +Filter several columns at once and the grid shows rows that match every filter. Clear from the header menu (**Clear Value Filter**, **Clear All Value Filters**) or check **Select All** in the popover. -Text mode shows syntax-highlighted JSON with brace matching. Tree mode shows a collapsible outline you can search, expand/collapse all, and right-click to copy values or key paths like `$.users[0].email`. - -JSON is minified on save. Invalid JSON falls back to text mode. - -{/* Screenshot: JSON editor with validation */} - - JSON viewer - JSON viewer +{/* Screenshot: column value filter popover (placeholder, replace later) */} + + Value filter popover + Value filter popover -### Hex Editor (BLOB/Binary) - -BLOB, BINARY, and VARBINARY cells open a hex editor popover. Edit as space-separated hex bytes (e.g., `48 65 6C 6C 6F`). Invalid input is highlighted in red as you type. The Cell Inspector sidebar also offers hex editing. - -BLOBs larger than 10 KB are read-only. Use an external hex editor for large binary data. +This filter runs on the rows already loaded, so it covers the current page only. To filter the whole table on the server, use the [Filter Panel](/features/filtering). -### Multi-Row Editing - -Select multiple rows with `Cmd+click` (non-contiguous) or `Shift+click` (range) on row numbers. Press `Delete` to remove selected rows. - -{/* Screenshot: Multiple rows selected for editing */} - - Multiple rows selected for editing - Multiple rows selected for editing - - -### Fill Column +### Resize and Hide -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. +Drag a column border to resize, or double-click it to fit the content. Widths are saved per table. -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. +Hide columns from the columns button in the status bar or the header menu. A hidden column isn't fetched, so hiding a large column makes the table load faster. The primary key is always fetched, so editing still works. -### Saving Changes +### Display Format -Changes are queued, not applied immediately. Manage pending changes with: +UUIDs and Unix timestamps render in a readable form when the column type and name match (for example a `BINARY(16)` column named `uuid`, or a `BIGINT` named `created_at`). Right-click a header and choose **Display As** to set the format per column. Toggle the automatic behavior in **Settings > Editor > Smart value detection**. -- **Preview SQL**: Click the **Preview SQL** button (eye icon) or press `Cmd+Shift+P` to review pending SQL statements before committing -- **Commit**: Click the **Commit** button (or press `Cmd+S`) to apply all pending changes -- **Discard**: Click the **Discard** button to revert all pending changes -- **Undo/Redo**: Press `Cmd+Z` to undo or `Cmd+Shift+Z` to redo individual edits before committing - -See [Change Tracking](/features/change-tracking) for full details on how changes are queued and applied. +## Editing -Editing is available for simple `SELECT * FROM table` queries. Complex queries with joins or aggregations are read-only. +Editing works on simple `SELECT * FROM table` queries. Joins, aggregations, and [Safe Mode](/features/safe-mode) connections are read-only. -### Adding and Deleting Rows - -Click the **+** button to add a new row, or select row(s) and press `Delete` to remove them. Changes are queued until you click **Commit**. - -## Change Indicators - -Pending changes get visual feedback: - -- **Modified cells** are highlighted with a distinct background color -- **New rows** display an insertion indicator -- **Deleted rows** display a deletion indicator -- The **toolbar** shows the count of pending changes - -## Cell Inspector - -The Cell Inspector is a right sidebar panel showing detailed information about the selected row or current table. Toggle with `Cmd+Shift+B` or via **View** > **Toggle Cell Inspector**. - -{/* Screenshot: Cell Inspector showing row details */} - - Cell Inspector - Cell Inspector +Double-click a cell to edit it. Press `Enter` to confirm or `Escape` to cancel. Some types open a dedicated editor: + +| Column type | Editor | +|---|---| +| Date, time, datetime, timestamp | Calendar and time picker, or edit as text | +| Foreign key | Searchable list of referenced values | +| Boolean, `BIT`, `TINYINT(1)` | Checkbox, with a third state for nullable columns | +| `ENUM` | Searchable value list | +| `SET` (MySQL/MariaDB) | Multi-select checkboxes | +| `JSON`, `JSONB` | Text and Tree viewer | +| `BLOB`, binary | Hex editor (read-only over 10 KB) | + + + Cell editor + Cell editor -### Row Details Mode - -When a row is selected, the inspector shows all column values with full content: - -- Search columns by name -- TEXT/VARCHAR columns get a multi-line editor -- JSON/JSONB columns show a compact preview with an expand button for the full JSON viewer -- Edits here are queued like inline edits - -### Table Info Mode - -When no row is selected, the inspector shows table metadata: name, row count, storage size, creation date, engine, charset, and collation. - - -Read-Only Safe Mode connections and complex query results are not editable. See [Safe Mode](/features/safe-mode). - - -## Keyboard Navigation - -Use arrow keys to move between cells. Press `Enter` to edit, `Escape` to cancel. `Tab` / `Shift+Tab` move to the next/previous cell. `Cmd+Home` / `Cmd+End` jump to first/last row. - - -## Selecting & Copying - -Click a cell to select it. Drag or Shift+click to select a range. Click row numbers for entire rows; Cmd+click for non-contiguous rows. +### Foreign Keys -`Cmd+C` copies the focused cell value when a single row is selected and a cell has focus; otherwise it copies the selected row(s) as TSV. `Cmd+Shift+C` always copies the selected row(s) as TSV. `Cmd+Option+J` copies as JSON. +Foreign key cells show an arrow on the right edge. Click it to open the referenced table filtered to the matching row; `Cmd`-click opens a new tab. Right-click for **Preview Referenced Row**, which shows the row in a popover. -Right-click a row and choose **Copy as** for additional formats: + + Foreign key lookup + Foreign key lookup + -| Format | Description | -|--------|-------------| -| Cell Value | The clicked cell's content | -| With Headers | TSV with the column names as the first line | -| JSON | A JSON array of row objects | -| CSV | RFC 4180 CSV; empty fields for NULL | -| CSV with Headers | Same as CSV with the header row prepended | -| Markdown | GitHub-flavored Markdown table | -| IN Clause | `('a', 'b', 'c')` from the clicked column across selected rows, ready for `WHERE col IN (...)` | -| INSERT Statement(s) | SQL INSERT for each row (SQL databases only) | -| UPDATE Statement(s) | SQL UPDATE for each row by primary key (SQL databases only) | +### Add, Delete, and Fill -Right-click a column header and choose **Copy Column Values** to copy every loaded value in that column, one per line. +- **Add a row**: click **+** in the status bar. +- **Delete rows**: select rows by their row number (`Shift`-click for a range, `Cmd`-click for separate rows) and press `Delete`. +- **Fill a column**: right-click a header and choose **Fill Column** to set one value across all loaded rows. It skips primary key columns. -Copying rows reflects the grid as shown: hidden columns are left out and the columns follow their current order. Copy as UPDATE always keeps the primary key so the WHERE clause stays correct, even when that column is hidden. +### Saving Changes -{/* Screenshot: Copy options in context menu */} - - Copy options in context menu - Copy options in context menu - +Edits are queued, not applied right away. The toolbar shows the pending count, modified cells are highlighted, and new and deleted rows are marked. -## Pagination +- **Preview SQL** (`Cmd+Shift+P`): review the statements before applying. +- **Commit** (`Cmd+S`): apply all pending changes. +- **Discard**: drop all pending changes. +- **Undo / Redo** (`Cmd+Z` / `Cmd+Shift+Z`): step through edits before committing. -Table tabs paginate large result sets. The status bar shows a rows-per-page menu and First / Previous / Next / Last buttons, with a page indicator between them. +See [Change Tracking](/features/change-tracking) for details. -- **Rows per page**: pick a preset (5, 10, 20, 100, 500, 1,000), enter a custom size, or choose **All rows** to load the whole table on one page. Loading all rows asks for confirmation first, since large tables use a lot of memory. -- **Page navigation**: jump to the first or last page, step one page at a time, or click the page indicator to go to a specific page. -- The bar also appears for filtered tables whose total row count is unknown. There the Next button stays available while a full page keeps loading, and the indicator shows the page number without a total. +## Cell Inspector -Set the default page size in **Settings** > **Editor** (Small: 100, Medium: 500, Large: 1,000, Custom: any value from 10 to 100,000). Smaller pages load faster. +Toggle the inspector with `Cmd+Shift+B`. With a row selected, it lists every column value with a full editor for long text and JSON. With no row selected, it shows table metadata: row count, size, engine, charset, and collation. -{/* Screenshot: Pagination controls with page navigation */} - - Pagination controls with page navigation - Pagination controls with page navigation + + Cell Inspector + Cell Inspector -## Smart Value Detection +## Select and Copy -Auto-renders UUIDs and Unix timestamps in the grid when column type and name match: +Click a cell to select it, drag or `Shift`-click for a range, and click row numbers for whole rows. -| Column Type | Name Contains | Display | -|------------|--------------|---------| -| `BINARY(16)` | `uuid`, `guid`, `_id` | `550e8400-e29b-41d4-a716-446655440000` | -| `CHAR(32)`, `VARCHAR(36)` | `uuid`, `guid` | UUID with hyphens | -| `INT`, `BIGINT` | `_at`, `_time`, `_timestamp`, `created`, `updated` | `2025-01-15 10:30:00` | +- `Cmd+C`: the focused cell, or the selected rows as TSV. +- `Cmd+Shift+C`: the selected rows as TSV. +- `Cmd+Option+J`: the selected rows as JSON. -Right-click a column header > **Display As** to override per column. Overrides persist per connection and table. Toggle auto-detection in **Settings** > **Editor** > **Smart value detection**. +Right-click a row and choose **Copy as** for more formats: -## NULL Values and Display +| Format | Output | +|---|---| +| With Headers | TSV with a header row | +| JSON | Array of row objects | +| CSV / CSV with Headers | RFC 4180 CSV | +| Markdown | Markdown table | +| IN Clause | `('a', 'b', 'c')` for `WHERE col IN (...)` | +| INSERT / UPDATE | SQL statements per row (SQL databases) | -NULL values show as styled "NULL" text (customizable in **Settings** > **Editor**). Configure date format, row height, and alternate row colors in the same settings panel. +Copies follow the grid as shown: hidden columns are left out and columns keep their current order. **Copy as UPDATE** always includes the primary key. Right-click a header and choose **Copy Column Values** to copy a whole column, one value per line. - -## Result Truncation - -Query tabs cap results at 10,000 rows by default to keep the UI responsive on large queries. When the cap kicks in, the status bar shows a truncation marker and a Fetch All button: - - - Truncation banner with Fetch All button - Truncation banner with Fetch All button + + Copy options + Copy options -- **Showing N rows (truncated)** means the query returned more than the cap and the grid is showing the first N rows -- **Fetch All** re-runs the query without a cap. A confirmation appears first because large result sets use significant memory. +## Pagination and Limits -Your `LIMIT` and `OFFSET` are passed to the database unchanged. `SELECT ... LIMIT 10` returns 10 rows whether the cap is set or not. The cap only kicks in when the database returns more rows than your cap allows, which happens on queries with no `LIMIT` or with a `LIMIT` larger than the cap. +**Table tabs** page through data. The status bar has a rows-per-page menu (5 to 1,000, a custom size, or **All rows**) and First / Previous / Next / Last controls. Set the default in **Settings > Editor**. Smaller pages load faster. -Configure the cap in **Settings** > **Editor**: + + Pagination controls + Pagination controls + -- **Truncate query results**: turns the cap on or off -- **Row cap**: 100 to 500,000, or 0 for unlimited +**Query tabs** cap results at 10,000 rows by default to stay responsive. When the cap applies, the status bar shows **Fetch All** to load the rest (with a confirmation, since large results use a lot of memory). Your own `LIMIT` and `OFFSET` are passed through unchanged. Adjust the cap in **Settings > Editor** (**Truncate query results**, **Row cap**). -Table tabs are not affected by this setting. They use [Pagination](#pagination) instead. +Press `Cmd+.` to cancel a running query or a Fetch All. -### Cancelling +## Display Settings -Press `Cmd+.` or click the stop button in the toolbar to cancel a running query or a Fetch All operation. +NULL shows as styled `NULL` text. Set the NULL display, date format, row height, and alternate row colors in **Settings > Editor**. ## MongoDB Collections -### Schema and Display - -MongoDB has no fixed schema. TablePro infers columns by sampling up to 500 documents. Missing fields show as NULL. The `_id` column is read-only; delete and re-insert to change it. - -BSON types display as: `ObjectId("...")`, ISO 8601 dates, `BinData(subtype, "base64...")`, decimal strings, `DBRef("collection", id)`. - -### MQL Statement Preview - -Changes preview as MongoDB shell commands: -- Inserts: `db.collection.insertMany([...])` -- Updates: `db.collection.updateOne({filter}, {$set: {...}})` -- Deletes: `db.collection.deleteMany({_id: {$in: [...]}})` - +MongoDB has no fixed schema, so TablePro infers columns by sampling up to 500 documents. Missing fields show as NULL, and the `_id` column is read-only (delete and re-insert to change it). BSON values display as `ObjectId("...")`, ISO 8601 dates, `BinData(...)`, decimal strings, and `DBRef(...)`. +Changes preview as shell commands: `insertMany`, `updateOne` with `$set`, and `deleteMany`.