Skip to content

Commit f622ffc

Browse files
committed
feat(datagrid): add per-column value filter (#1454)
1 parent 0d042a4 commit f622ffc

18 files changed

Lines changed: 964 additions & 48 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- 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)
13+
- 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)
1314
- 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)
1415
- 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)
1516

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//
2+
// GridValueFilter.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct ColumnValueFilter: Equatable {
9+
var selectedValues: Set<String>
10+
var includesNull: Bool
11+
12+
var hidesEverything: Bool { selectedValues.isEmpty && !includesNull }
13+
}
14+
15+
struct ColumnDistinctValue: Identifiable, Equatable {
16+
let display: String
17+
let isNull: Bool
18+
let count: Int
19+
20+
var id: String { isNull ? "\u{0}<null>" : "v:\(display)" }
21+
}
22+
23+
struct GridValueFilterState: Equatable {
24+
private(set) var filters: [Int: ColumnValueFilter] = [:]
25+
private(set) var columnNames: [Int: String] = [:]
26+
27+
var isActive: Bool { !filters.isEmpty }
28+
var activeColumnCount: Int { filters.count }
29+
var activeColumns: Set<Int> { Set(filters.keys) }
30+
31+
func isActive(column dataIndex: Int) -> Bool { filters[dataIndex] != nil }
32+
33+
func filter(forColumn dataIndex: Int) -> ColumnValueFilter? { filters[dataIndex] }
34+
35+
mutating func set(_ filter: ColumnValueFilter, columnName: String, forColumn dataIndex: Int) {
36+
filters[dataIndex] = filter
37+
columnNames[dataIndex] = columnName
38+
}
39+
40+
mutating func clear(column dataIndex: Int) {
41+
filters.removeValue(forKey: dataIndex)
42+
columnNames.removeValue(forKey: dataIndex)
43+
}
44+
45+
mutating func clearAll() {
46+
filters.removeAll()
47+
columnNames.removeAll()
48+
}
49+
50+
mutating func prune(againstColumns columns: [String]) {
51+
for (dataIndex, name) in columnNames where dataIndex >= columns.count || columns[dataIndex] != name {
52+
filters.removeValue(forKey: dataIndex)
53+
columnNames.removeValue(forKey: dataIndex)
54+
}
55+
}
56+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
//
2+
// ColumnValueFilterPopover.swift
3+
// TablePro
4+
//
5+
6+
import SwiftUI
7+
8+
struct ColumnValueFilterPopover: View {
9+
let columnName: String
10+
let values: [ColumnDistinctValue]
11+
let loadedRowCount: Int
12+
let onApply: (ColumnValueFilter?) -> Void
13+
let onCancel: () -> Void
14+
15+
@State private var checkedValues: Set<String>
16+
@State private var nullChecked: Bool
17+
@State private var searchText: String = ""
18+
19+
private static let nullLabel = String(localized: "(NULL)")
20+
private static let emptyLabel = String(localized: "(Empty)")
21+
22+
init(
23+
columnName: String,
24+
values: [ColumnDistinctValue],
25+
loadedRowCount: Int,
26+
initialFilter: ColumnValueFilter?,
27+
onApply: @escaping (ColumnValueFilter?) -> Void,
28+
onCancel: @escaping () -> Void
29+
) {
30+
self.columnName = columnName
31+
self.values = values
32+
self.loadedRowCount = loadedRowCount
33+
self.onApply = onApply
34+
self.onCancel = onCancel
35+
if let initialFilter {
36+
_checkedValues = State(initialValue: initialFilter.selectedValues)
37+
_nullChecked = State(initialValue: initialFilter.includesNull)
38+
} else {
39+
_checkedValues = State(initialValue: Set(values.filter { !$0.isNull }.map(\.display)))
40+
_nullChecked = State(initialValue: values.contains { $0.isNull })
41+
}
42+
}
43+
44+
var body: some View {
45+
VStack(alignment: .leading, spacing: 0) {
46+
header
47+
Divider()
48+
controls
49+
Divider()
50+
valueList
51+
Divider()
52+
footer
53+
}
54+
.frame(width: 260)
55+
}
56+
57+
private var header: some View {
58+
VStack(alignment: .leading, spacing: 1) {
59+
Text(columnName)
60+
.font(.headline)
61+
.lineLimit(1)
62+
.truncationMode(.middle)
63+
Text(loadedRowsCaption)
64+
.font(.caption)
65+
.foregroundStyle(.secondary)
66+
}
67+
.frame(maxWidth: .infinity, alignment: .leading)
68+
.padding(.horizontal, 14)
69+
.padding(.top, 12)
70+
.padding(.bottom, 8)
71+
}
72+
73+
private var loadedRowsCaption: String {
74+
String(format: String(localized: "Values from %d loaded rows"), loadedRowCount)
75+
}
76+
77+
private var controls: some View {
78+
VStack(spacing: 8) {
79+
NativeSearchField(
80+
text: $searchText,
81+
placeholder: String(localized: "Search values"),
82+
onSubmit: { apply() },
83+
focusOnAppear: true,
84+
accessibilityIdentifier: "value-filter-search"
85+
)
86+
HStack(spacing: 6) {
87+
TristateCheckbox(state: selectAllState) { toggleSelectAll() }
88+
Text("Select All")
89+
Spacer()
90+
}
91+
}
92+
.padding(.horizontal, 14)
93+
.padding(.vertical, 8)
94+
}
95+
96+
private var valueList: some View {
97+
List {
98+
ForEach(filteredValues) { value in
99+
Toggle(isOn: binding(for: value)) {
100+
HStack(spacing: 8) {
101+
Text(label(for: value))
102+
.lineLimit(1)
103+
.truncationMode(.tail)
104+
.foregroundStyle(value.isNull ? Color.secondary : Color.primary)
105+
Spacer(minLength: 8)
106+
Text("\(value.count)")
107+
.font(.callout.monospacedDigit())
108+
.foregroundStyle(.secondary)
109+
}
110+
}
111+
.toggleStyle(.checkbox)
112+
}
113+
}
114+
.listStyle(.plain)
115+
.scrollContentBackground(.hidden)
116+
.frame(height: 200)
117+
}
118+
119+
private var footer: some View {
120+
HStack {
121+
Spacer()
122+
Button(role: .cancel) {
123+
onCancel()
124+
} label: {
125+
Text("Cancel")
126+
}
127+
.keyboardShortcut(.cancelAction)
128+
Button {
129+
apply()
130+
} label: {
131+
Text("Apply")
132+
}
133+
.keyboardShortcut(.defaultAction)
134+
.disabled(nothingSelected)
135+
}
136+
.padding(14)
137+
}
138+
139+
private var filteredValues: [ColumnDistinctValue] {
140+
guard !searchText.isEmpty else { return values }
141+
return values.filter { label(for: $0).localizedCaseInsensitiveContains(searchText) }
142+
}
143+
144+
private var selectAllState: TristateCheckbox.State {
145+
let selected = values.filter { $0.isNull ? nullChecked : checkedValues.contains($0.display) }.count
146+
if selected == 0 { return .unchecked }
147+
if selected == values.count { return .checked }
148+
return .mixed
149+
}
150+
151+
private var nothingSelected: Bool {
152+
checkedValues.isEmpty && !nullChecked
153+
}
154+
155+
private func label(for value: ColumnDistinctValue) -> String {
156+
if value.isNull { return Self.nullLabel }
157+
return value.display.isEmpty ? Self.emptyLabel : value.display
158+
}
159+
160+
private func binding(for value: ColumnDistinctValue) -> Binding<Bool> {
161+
if value.isNull {
162+
return Binding(get: { nullChecked }, set: { nullChecked = $0 })
163+
}
164+
return Binding(
165+
get: { checkedValues.contains(value.display) },
166+
set: { isOn in
167+
if isOn {
168+
checkedValues.insert(value.display)
169+
} else {
170+
checkedValues.remove(value.display)
171+
}
172+
}
173+
)
174+
}
175+
176+
private func toggleSelectAll() {
177+
setAll(selectAllState != .checked)
178+
}
179+
180+
private func setAll(_ selected: Bool) {
181+
if selected {
182+
checkedValues = Set(values.filter { !$0.isNull }.map(\.display))
183+
nullChecked = values.contains { $0.isNull }
184+
} else {
185+
checkedValues = []
186+
nullChecked = false
187+
}
188+
}
189+
190+
private func apply() {
191+
if selectAllState == .checked {
192+
onApply(nil)
193+
} else {
194+
onApply(ColumnValueFilter(selectedValues: checkedValues, includesNull: nullChecked))
195+
}
196+
}
197+
}

0 commit comments

Comments
 (0)