Skip to content

Commit d8ac3d0

Browse files
committed
feat: Add context menu filtering and auto-scroll for filter panel
- Add 'Filter with column' option to column header context menu - Implement addFilterForColumn method in FilterStateManager to pre-populate filter with selected column - Add onFilterColumn callback to DataGridView for context menu integration - Wire up filter creation from column header to FilterStateManager in MainContentView - Add auto-scroll functionality using ScrollViewReader to show newly added filters - Filters now automatically scroll into view with smooth animation when added - Improve UX by reducing manual steps in filter creation workflow
1 parent fc31a4d commit d8ac3d0

6 files changed

Lines changed: 115 additions & 18 deletions

File tree

OpenTable.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@
253253
AUTOMATION_APPLE_EVENTS = NO;
254254
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
255255
CODE_SIGN_STYLE = Automatic;
256-
CURRENT_PROJECT_VERSION = 1;
256+
CURRENT_PROJECT_VERSION = 3;
257257
DEVELOPMENT_TEAM = D7HJ5TFYCU;
258258
ENABLE_APP_SANDBOX = NO;
259259
ENABLE_HARDENED_RUNTIME = YES;
@@ -331,7 +331,7 @@
331331
AUTOMATION_APPLE_EVENTS = NO;
332332
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
333333
CODE_SIGN_STYLE = Automatic;
334-
CURRENT_PROJECT_VERSION = 1;
334+
CURRENT_PROJECT_VERSION = 3;
335335
DEVELOPMENT_TEAM = D7HJ5TFYCU;
336336
ENABLE_APP_SANDBOX = NO;
337337
ENABLE_HARDENED_RUNTIME = YES;

OpenTable/Models/FilterState.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ final class FilterStateManager: ObservableObject {
6666
focusedFilterId = newFilter.id
6767
}
6868

69+
/// Add a new filter with a specific column pre-selected (for context menu "Filter with column")
70+
func addFilterForColumn(_ columnName: String) {
71+
let settings = settingsStorage.loadSettings()
72+
var newFilter = TableFilter()
73+
74+
// Set the specified column
75+
newFilter.columnName = columnName
76+
77+
// Apply default operator setting
78+
newFilter.filterOperator = settings.defaultOperator.toFilterOperator()
79+
80+
// New filters should be selected by default for "Apply All"
81+
newFilter.isSelected = true
82+
83+
filters.append(newFilter)
84+
focusedFilterId = newFilter.id
85+
86+
// Show panel if hidden
87+
if !isVisible {
88+
show()
89+
}
90+
}
91+
6992
/// Duplicate a filter
7093
func duplicateFilter(_ filter: TableFilter) {
7194
var copy = filter

OpenTable/Views/MainContentView.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,9 @@ struct MainContentView: View {
437437
onUndoInsert: { rowIndex in
438438
undoInsertRow(at: rowIndex)
439439
},
440+
onFilterColumn: { columnName in
441+
filterStateManager.addFilterForColumn(columnName)
442+
},
440443
selectedRowIndices: $selectedRowIndices,
441444
sortState: sortStateBinding,
442445
editingCell: $editingCell
@@ -470,6 +473,47 @@ struct MainContentView: View {
470473
.frame(minHeight: 150)
471474
.animation(.easeInOut(duration: 0.2), value: filterStateManager.isVisible)
472475
}
476+
477+
// MARK: - Data Grid Section
478+
479+
@ViewBuilder
480+
private func dataGridSection(tab: QueryTab) -> some View {
481+
if tab.showStructure, let tableName = tab.tableName {
482+
TableStructureView(tableName: tableName, connection: connection)
483+
.frame(maxHeight: .infinity)
484+
} else {
485+
DataGridView(
486+
rowProvider: InMemoryRowProvider(
487+
rows: sortedRows(for: tab),
488+
columns: tab.resultColumns,
489+
columnDefaults: tab.columnDefaults
490+
),
491+
changeManager: changeManager,
492+
isEditable: tab.isEditable,
493+
onCommit: { sql in
494+
executeCommitSQL(sql)
495+
},
496+
onRefresh: { runQuery() },
497+
onCellEdit: { rowIndex, colIndex, newValue in
498+
updateCellInTab(rowIndex: rowIndex, columnIndex: colIndex, value: newValue)
499+
},
500+
onSort: { columnIndex, ascending in
501+
handleSort(columnIndex: columnIndex, ascending: ascending)
502+
},
503+
onAddRow: { addNewRow() },
504+
onUndoInsert: { rowIndex in
505+
undoInsertRow(at: rowIndex)
506+
},
507+
onFilterColumn: { columnName in
508+
filterStateManager.addFilterForColumn(columnName)
509+
},
510+
selectedRowIndices: $selectedRowIndices,
511+
sortState: sortStateBinding,
512+
editingCell: $editingCell
513+
)
514+
.frame(maxHeight: .infinity, alignment: .top)
515+
}
516+
}
473517

474518
// MARK: - Status Bar
475519

OpenTable/Views/Results/DataGridView.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ struct DataGridView: NSViewRepresentable {
4040
var onSort: ((Int, Bool) -> Void)? // Called when column header clicked (columnIndex, ascending)
4141
var onAddRow: (() -> Void)? // Called when user triggers add row (Cmd+N)
4242
var onUndoInsert: ((Int) -> Void)? // Called when user undoes row insertion (rowIndex)
43+
var onFilterColumn: ((String) -> Void)? // Called when user selects "Filter with column" from header context menu
4344

4445
@Binding var selectedRowIndices: Set<Int>
4546
@Binding var sortState: SortState
@@ -168,6 +169,7 @@ struct DataGridView: NSViewRepresentable {
168169
coordinator.onSort = onSort
169170
coordinator.onAddRow = onAddRow
170171
coordinator.onUndoInsert = onUndoInsert
172+
coordinator.onFilterColumn = onFilterColumn
171173

172174
// PERF: Rebuild visual state cache once per update cycle
173175
// Cells read from this cache instead of calling changeManager directly
@@ -411,6 +413,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
411413

412414
/// Callback when user undoes row insertion
413415
var onUndoInsert: ((Int) -> Void)?
416+
417+
/// Callback when user selects "Filter with column" from header context menu
418+
var onFilterColumn: ((String) -> Void)?
414419

415420
// MARK: - NSTableViewDataSource
416421

@@ -481,13 +486,27 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData
481486
copyItem.representedObject = column.title
482487
copyItem.target = self
483488
menu.addItem(copyItem)
489+
490+
// Add "Filter with column" menu item
491+
let filterItem = NSMenuItem(
492+
title: "Filter with column",
493+
action: #selector(filterWithColumn(_:)),
494+
keyEquivalent: "")
495+
filterItem.representedObject = column.title
496+
filterItem.target = self
497+
menu.addItem(filterItem)
484498
}
485499

486500
@objc private func copyColumnName(_ sender: NSMenuItem) {
487501
guard let columnName = sender.representedObject as? String else { return }
488502
NSPasteboard.general.clearContents()
489503
NSPasteboard.general.setString(columnName, forType: .string)
490504
}
505+
506+
@objc private func filterWithColumn(_ sender: NSMenuItem) {
507+
guard let columnName = sender.representedObject as? String else { return }
508+
onFilterColumn?(columnName)
509+
}
491510

492511
// MARK: - NSTableViewDelegate
493512

OpenTable/Views/Results/FilterPanelView.swift

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -231,25 +231,36 @@ struct FilterPanelView: View {
231231
// MARK: - Filter List
232232

233233
private var filterList: some View {
234-
ScrollView {
235-
LazyVStack(spacing: 2) {
236-
ForEach(filterState.filters) { filter in
237-
FilterRowView(
238-
filter: filterState.binding(for: filter),
239-
columns: columns,
240-
isFocused: filterState.focusedFilterId == filter.id,
241-
onDuplicate: { filterState.duplicateFilter(filter) },
242-
onRemove: { filterState.removeFilter(filter) },
243-
onApply: { applySingleFilter(filter) },
244-
onFocus: { filterState.focusedFilterId = filter.id }
245-
)
234+
ScrollViewReader { proxy in
235+
ScrollView {
236+
LazyVStack(spacing: 2) {
237+
ForEach(filterState.filters) { filter in
238+
FilterRowView(
239+
filter: filterState.binding(for: filter),
240+
columns: columns,
241+
isFocused: filterState.focusedFilterId == filter.id,
242+
onDuplicate: { filterState.duplicateFilter(filter) },
243+
onRemove: { filterState.removeFilter(filter) },
244+
onApply: { applySingleFilter(filter) },
245+
onFocus: { filterState.focusedFilterId = filter.id }
246+
)
247+
.id(filter.id) // Make each row identifiable for scrollTo
248+
}
249+
}
250+
.padding(.horizontal, 8)
251+
.padding(.vertical, 4)
252+
}
253+
// Dynamic height: ~40pt per row, max 4 rows visible before scrolling
254+
.frame(maxHeight: min(CGFloat(filterState.filters.count) * 40 + 8, 160))
255+
.onChange(of: filterState.focusedFilterId) { _, newFocusedId in
256+
// Auto-scroll to the focused filter (newly added or explicitly focused)
257+
if let focusedId = newFocusedId {
258+
withAnimation(.easeInOut(duration: 0.25)) {
259+
proxy.scrollTo(focusedId, anchor: .bottom)
260+
}
246261
}
247262
}
248-
.padding(.horizontal, 8)
249-
.padding(.vertical, 4)
250263
}
251-
// Dynamic height: ~40pt per row, max 4 rows visible before scrolling
252-
.frame(maxHeight: min(CGFloat(filterState.filters.count) * 40 + 8, 160))
253264
}
254265

255266
// MARK: - Footer

0 commit comments

Comments
 (0)