From fa8a7950ffd49f69ff7aca8793c1c7b40f61ee32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 10 Jun 2026 22:26:57 +0700 Subject: [PATCH 1/3] feat(datagrid): redesign filter panel header and row actions - Rename 'Unset' -> 'Clear': calls clearAppliedFilters() (keeps rows, returns to unfiltered); old clearFilterState() moved to 'Remove All Filters' in ... menu - Remove per-row Apply/Applied buttons; 'Apply Only This Filter' added to row context menu via rightMouseDown - Tri-state checkbox in header toggles all filter rows enabled/disabled at once --- CHANGELOG.md | 3 ++ TablePro/Views/Filter/FilterPanelView.swift | 53 +++++++++++++++++++-- TablePro/Views/Filter/FilterRowView.swift | 30 ++++-------- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32625cb83..7612e948f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509) +- Filter panel header "Unset" renamed to "Clear": it now keeps filter rows in place and only removes the applied state, returning the table to unfiltered results. Use "Remove All Filters" in the filter options menu to discard all filter rows at once. +- Per-row Apply and Applied buttons removed from the filter panel; "Apply Only This Filter" is now in each row's right-click context menu. +- A tri-state checkbox in the filter panel header toggles all filter rows enabled or disabled at once. ### Fixed diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 7c413aa74..f594f20e3 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -67,8 +67,43 @@ struct FilterPanelView: View { .onPreferenceChange(FilterRowsHeightKey.self) { filterRowsHeight = $0 } } + private var allFiltersCheckboxImage: String { + switch allFiltersEnabledState { + case true: return "checkmark.square.fill" + case false: return "square" + case .none: return "minus.square.fill" + } + } + + private var allFiltersEnabledState: Bool? { + guard !filterState.filters.isEmpty else { return false } + let enabledCount = filterState.filters.count { $0.isEnabled } + if enabledCount == filterState.filters.count { return true } + if enabledCount == 0 { return false } + return nil + } + + private func toggleAllFiltersEnabled() { + let allEnabled = filterState.filters.allSatisfy { $0.isEnabled } + let newState = !allEnabled + for filter in filterState.filters { + var updated = filter + updated.isEnabled = newState + coordinator.updateFilter(updated) + } + } + private var filterHeader: some View { HStack(spacing: 8) { + if !filterState.filters.isEmpty { + Button(action: toggleAllFiltersEnabled) { + Image(systemName: allFiltersCheckboxImage) + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + .help(String(localized: "Enable or disable all filters")) + } + Text("Filters") .font(.callout.weight(.medium)) @@ -88,15 +123,15 @@ struct FilterPanelView: View { filterOptionsMenu - Button("Unset") { - coordinator.clearFilterState() + Button("Clear") { + coordinator.clearAppliedFilters() onUnset() coordinator.focusActiveGrid() } .buttonStyle(.bordered) .controlSize(.small) .disabled(!filterState.hasAppliedFilters) - .help(String(localized: "Remove all filters and reload")) + .help(String(localized: "Clear applied filters without removing filter rows")) Button("Apply") { applyAllValidFilters() @@ -172,6 +207,17 @@ struct FilterPanelView: View { Divider() + Button(role: .destructive) { + coordinator.clearFilterState() + onUnset() + coordinator.focusActiveGrid() + } label: { + Label(String(localized: "Remove All Filters"), systemImage: "xmark.circle") + } + .disabled(filterState.filters.isEmpty) + + Divider() + Button { showSettingsPopover.toggle() } label: { @@ -198,7 +244,6 @@ struct FilterPanelView: View { completions: completionItems(), enumValuesByColumn: enumValuesByColumn, rawSQLCompletionProvider: rawSQLCompletionProvider, - isApplied: filterState.commit == .solo(filter.id), onAdd: { coordinator.addFilter(columns: columns, primaryKeyColumn: primaryKeyColumn) focusedFilterId = filterState.filters.last?.id diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index c9ebcc9a9..b0491e613 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -11,7 +11,6 @@ struct FilterRowView: View { let completions: [String] var enumValuesByColumn: [String: [String]] = [:] var rawSQLCompletionProvider: RawSQLFilterCompletionProvider? - let isApplied: Bool let onAdd: () -> Void let onDuplicate: () -> Void let onRemove: () -> Void @@ -160,28 +159,8 @@ struct FilterRowView: View { } } - @ViewBuilder - private var soloApplyButton: some View { - if isApplied { - Button(String(localized: "Applied"), action: onApply) - .buttonStyle(.borderedProminent) - } else { - Button(String(localized: "Apply"), action: onApply) - .buttonStyle(.bordered) - } - } - private var rowButtons: some View { HStack(spacing: 4) { - soloApplyButton - .controlSize(.small) - .disabled(!filter.isValid) - .accessibilityLabel(String(localized: "Apply only this filter")) - .accessibilityValue(isApplied ? String(localized: "Applied") : "") - .help(isApplied - ? String(localized: "Filtering by only this row") - : String(localized: "Filter by only this row")) - Button(action: onAdd) { Image(systemName: "plus") .frame(width: rowButtonGlyphSize, height: rowButtonGlyphSize) @@ -204,6 +183,15 @@ struct FilterRowView: View { @ViewBuilder private var rowContextMenu: some View { + Button { + onApply() + } label: { + Label(String(localized: "Apply Only This Filter"), systemImage: "checkmark.circle") + } + .disabled(!filter.isValid) + + Divider() + Button { onAdd() } label: { From e76906c8f16977e04c502f514208d351aa2260ca Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 15 Jun 2026 23:09:54 +0700 Subject: [PATCH 2/3] refactor(datagrid): extract filter tri-state logic and fix changelog sections --- CHANGELOG.md | 10 +++--- TablePro/Models/Database/TableFilter.swift | 7 ++++ TablePro/Views/Filter/FilterPanelView.swift | 17 +++------ .../Models/TabFilterStateTests.swift | 36 +++++++++++++++++++ 4 files changed, 53 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2874a81..194ececb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The tree sidebar can show only the databases you pick. Use the filter button to check the ones you want, with a search box for long lists. The choice is saved per connection. (#1667) - Closing a query tab no longer loses unsaved SQL. The next blank query tab you open for the same connection brings the last closed draft back. (#1686) +- A checkbox in the filter panel header turns every filter row on or off at once. It shows a dash when only some rows are on. + +### Changed + +- The filter panel's "Unset" button is now "Clear". It keeps your filter rows and only drops the applied state, so the table returns to unfiltered results. To remove the rows themselves, use "Remove All Filters" in the filter options menu. +- Apply a single filter on its own from a row's right-click menu with "Apply Only This Filter". The inline per-row Apply button is gone. ### Fixed -- Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509) -- Filter panel header "Unset" renamed to "Clear": it now keeps filter rows in place and only removes the applied state, returning the table to unfiltered results. Use "Remove All Filters" in the filter options menu to discard all filter rows at once. -- Per-row Apply and Applied buttons removed from the filter panel; "Apply Only This Filter" is now in each row's right-click context menu. -- A tri-state checkbox in the filter panel header toggles all filter rows enabled or disabled at once. - Oracle connections no longer crash the app during connect. A short or unexpected handshake packet from the server (such as session-setup metadata or an error) now surfaces the error or continues instead of trapping. (#1683) - MongoDB filters on `_id` and other ObjectId fields now match. A 24-character hex value is matched as an ObjectId as well as a string, so filtering by `_id` returns the row instead of nothing. (#1682) - The sidebar and inspector keep their width per connection, the sidebar keeps its collapsed state, and the inspector keeps its selected tab, when you quit and reopen the app. diff --git a/TablePro/Models/Database/TableFilter.swift b/TablePro/Models/Database/TableFilter.swift index 639985325..b3b906923 100644 --- a/TablePro/Models/Database/TableFilter.swift +++ b/TablePro/Models/Database/TableFilter.swift @@ -232,4 +232,11 @@ struct TabFilterState: Equatable, Hashable, Codable { var hasAppliedFilters: Bool { !appliedFilters.isEmpty } + + var allEnabledState: Bool? { + guard !filters.isEmpty else { return false } + if filters.allSatisfy({ $0.isEnabled }) { return true } + if filters.allSatisfy({ !$0.isEnabled }) { return false } + return nil + } } diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index f594f20e3..6773a3c1e 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -68,24 +68,15 @@ struct FilterPanelView: View { } private var allFiltersCheckboxImage: String { - switch allFiltersEnabledState { - case true: return "checkmark.square.fill" - case false: return "square" + switch filterState.allEnabledState { + case .some(true): return "checkmark.square.fill" + case .some(false): return "square" case .none: return "minus.square.fill" } } - private var allFiltersEnabledState: Bool? { - guard !filterState.filters.isEmpty else { return false } - let enabledCount = filterState.filters.count { $0.isEnabled } - if enabledCount == filterState.filters.count { return true } - if enabledCount == 0 { return false } - return nil - } - private func toggleAllFiltersEnabled() { - let allEnabled = filterState.filters.allSatisfy { $0.isEnabled } - let newState = !allEnabled + let newState = filterState.allEnabledState != true for filter in filterState.filters { var updated = filter updated.isEnabled = newState diff --git a/TableProTests/Models/TabFilterStateTests.swift b/TableProTests/Models/TabFilterStateTests.swift index 9a710e8b5..4fa26786a 100644 --- a/TableProTests/Models/TabFilterStateTests.swift +++ b/TableProTests/Models/TabFilterStateTests.swift @@ -75,6 +75,42 @@ struct TabFilterStateTests { #expect(decoded.appliedFilters.map(\.id) == [filter.id]) } + @Test("allEnabledState is false when there are no filters") + func allEnabledStateEmpty() { + let state = TabFilterState() + #expect(state.allEnabledState == false) + } + + @Test("allEnabledState is true when every filter is enabled") + func allEnabledStateAllOn() { + var state = TabFilterState() + state.filters = [ + TestFixtures.makeTableFilter(column: "id"), + TestFixtures.makeTableFilter(column: "name") + ] + #expect(state.allEnabledState == true) + } + + @Test("allEnabledState is false when every filter is disabled") + func allEnabledStateAllOff() { + var state = TabFilterState() + state.filters = [ + TestFixtures.makeTableFilter(column: "id", isEnabled: false), + TestFixtures.makeTableFilter(column: "name", isEnabled: false) + ] + #expect(state.allEnabledState == false) + } + + @Test("allEnabledState is nil when filters are mixed") + func allEnabledStateMixed() { + var state = TabFilterState() + state.filters = [ + TestFixtures.makeTableFilter(column: "id"), + TestFixtures.makeTableFilter(column: "name", isEnabled: false) + ] + #expect(state.allEnabledState == nil) + } + @Test("browseSearch reads and writes the key pattern fields") func browseSearchAccessor() { var state = TabFilterState() From 4daced2a35b6ac2035871fd1ee1d2a9759a443a2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 15 Jun 2026 23:20:35 +0700 Subject: [PATCH 3/3] refactor(datagrid): use native NSButton tristate checkbox for filter select-all --- .../Views/Components/TristateCheckbox.swift | 57 +++++++++++++++++++ .../Views/Export/ExportTableTreeView.swift | 43 -------------- TablePro/Views/Filter/FilterPanelView.swift | 18 ++---- 3 files changed, 62 insertions(+), 56 deletions(-) create mode 100644 TablePro/Views/Components/TristateCheckbox.swift diff --git a/TablePro/Views/Components/TristateCheckbox.swift b/TablePro/Views/Components/TristateCheckbox.swift new file mode 100644 index 000000000..6b9073026 --- /dev/null +++ b/TablePro/Views/Components/TristateCheckbox.swift @@ -0,0 +1,57 @@ +// +// TristateCheckbox.swift +// TablePro +// + +import AppKit +import SwiftUI + +struct TristateCheckbox: NSViewRepresentable { + enum State { + case unchecked, checked, mixed + + init(allEnabled: Bool?) { + switch allEnabled { + case .some(true): self = .checked + case .some(false): self = .unchecked + case .none: self = .mixed + } + } + } + + let state: State + let action: () -> Void + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(checkboxWithTitle: "", target: context.coordinator, action: #selector(Coordinator.clicked)) + button.allowsMixedState = true + button.setContentHuggingPriority(.defaultHigh, for: .horizontal) + button.setContentHuggingPriority(.defaultHigh, for: .vertical) + return button + } + + func updateNSView(_ button: NSButton, context: Context) { + switch state { + case .unchecked: button.state = .off + case .checked: button.state = .on + case .mixed: button.state = .mixed + } + context.coordinator.action = action + } + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + class Coordinator: NSObject { + var action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func clicked() { + action() + } + } +} diff --git a/TablePro/Views/Export/ExportTableTreeView.swift b/TablePro/Views/Export/ExportTableTreeView.swift index a3660d1ea..ad798fe01 100644 --- a/TablePro/Views/Export/ExportTableTreeView.swift +++ b/TablePro/Views/Export/ExportTableTreeView.swift @@ -178,46 +178,3 @@ struct ExportTableTreeView: View { } } } - -// MARK: - Tristate Checkbox - -/// Native macOS tristate checkbox using NSButton -private struct TristateCheckbox: NSViewRepresentable { - enum State { - case unchecked, checked, mixed - } - - let state: State - let action: () -> Void - - func makeNSView(context: Context) -> NSButton { - let button = NSButton(checkboxWithTitle: "", target: context.coordinator, action: #selector(Coordinator.clicked)) - button.allowsMixedState = true - button.setContentHuggingPriority(.defaultHigh, for: .horizontal) - button.setContentHuggingPriority(.defaultHigh, for: .vertical) - return button - } - - func updateNSView(_ button: NSButton, context: Context) { - switch state { - case .unchecked: button.state = .off - case .checked: button.state = .on - case .mixed: button.state = .mixed - } - context.coordinator.action = action - } - - func makeCoordinator() -> Coordinator { - Coordinator(action: action) - } - - class Coordinator: NSObject { - var action: () -> Void - init(action: @escaping () -> Void) { - self.action = action - } - @objc func clicked() { - action() - } - } -} diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 6773a3c1e..146721d9b 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -67,14 +67,6 @@ struct FilterPanelView: View { .onPreferenceChange(FilterRowsHeightKey.self) { filterRowsHeight = $0 } } - private var allFiltersCheckboxImage: String { - switch filterState.allEnabledState { - case .some(true): return "checkmark.square.fill" - case .some(false): return "square" - case .none: return "minus.square.fill" - } - } - private func toggleAllFiltersEnabled() { let newState = filterState.allEnabledState != true for filter in filterState.filters { @@ -87,12 +79,12 @@ struct FilterPanelView: View { private var filterHeader: some View { HStack(spacing: 8) { if !filterState.filters.isEmpty { - Button(action: toggleAllFiltersEnabled) { - Image(systemName: allFiltersCheckboxImage) - .foregroundStyle(.primary) - } - .buttonStyle(.plain) + TristateCheckbox( + state: TristateCheckbox.State(allEnabled: filterState.allEnabledState), + action: toggleAllFiltersEnabled + ) .help(String(localized: "Enable or disable all filters")) + .accessibilityLabel(String(localized: "Enable or disable all filters")) } Text("Filters")