diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a90a8edd..194ececb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ 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 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/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 7c413aa74..146721d9b 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -67,8 +67,26 @@ struct FilterPanelView: View { .onPreferenceChange(FilterRowsHeightKey.self) { filterRowsHeight = $0 } } + private func toggleAllFiltersEnabled() { + let newState = filterState.allEnabledState != true + 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 { + 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") .font(.callout.weight(.medium)) @@ -88,15 +106,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 +190,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 +227,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: { 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()