Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions TablePro/Models/Database/TableFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
57 changes: 57 additions & 0 deletions TablePro/Views/Components/TristateCheckbox.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
43 changes: 0 additions & 43 deletions TablePro/Views/Export/ExportTableTreeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
36 changes: 32 additions & 4 deletions TablePro/Views/Filter/FilterPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +74 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Clear/Apply available after disabling all applied filters

When the table is filtered via an .all commit and every applied row is enabled, clicking the new header checkbox reaches this path and flips every row to isEnabled = false without reloading. Because hasAppliedFilters is derived from the now-disabled rows, the panel immediately reports no applied filters and disables both Clear and Apply, while the grid is still showing the old filtered query; users then have to remove the rows or re-enable a filter to get back to an unfiltered table. Keep the applied-state controls in sync with the actual query, or allow this toggle to trigger/enable the clear reload path.

Useful? React with 👍 / 👎.

}
}

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))

Expand All @@ -88,15 +106,15 @@ struct FilterPanelView: View {

filterOptionsMenu

Button("Unset") {
coordinator.clearFilterState()
Button("Clear") {
coordinator.clearAppliedFilters()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer clearing applied filters until reload is confirmed

When there are unsaved row edits, onUnset() routes to clearFiltersAndReload(), which can be canceled by confirmDiscardChangesIfNeeded. Because this line clears the commit before that confirmation completes, canceling the prompt leaves the grid still running the old filtered query while the panel reports no applied filters and disables Clear. Move the state clear into the confirmed path (or roll it back on cancel) so UI state and the query stay in sync.

Useful? React with 👍 / 👎.

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()
Expand Down Expand Up @@ -172,6 +190,17 @@ struct FilterPanelView: View {

Divider()

Button(role: .destructive) {
coordinator.clearFilterState()
onUnset()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reloading when removing unapplied filter rows

When the menu is used while the panel only has draft rows (for example the auto-added blank row before anything has been applied), this still invokes onUnset(), which MainContentView wires to clearFiltersAndReload(). That path prompts to discard unsaved row edits, resets pagination, and runs a query even though no applied filter is affecting the table, so a harmless row cleanup can force a data reload/possible discard. Gate the reload on filterState.hasAppliedFilters and just clear the local rows otherwise.

Useful? React with 👍 / 👎.

coordinator.focusActiveGrid()
} label: {
Label(String(localized: "Remove All Filters"), systemImage: "xmark.circle")
}
.disabled(filterState.filters.isEmpty)

Divider()

Button {
showSettingsPopover.toggle()
} label: {
Expand All @@ -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
Expand Down
30 changes: 9 additions & 21 deletions TablePro/Views/Filter/FilterRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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: {
Expand Down
36 changes: 36 additions & 0 deletions TableProTests/Models/TabFilterStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading