Skip to content

Commit f634a3c

Browse files
committed
feat(datagrid): add per-column value filter (#1454)
1 parent bcfb71d commit f634a3c

17 files changed

Lines changed: 983 additions & 47 deletions

CHANGELOG.md

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

1010
### Added
1111

12+
- 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)
1213
- 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)
1314

1415
### Fixed
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: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
@FocusState private var searchFocused: Bool
19+
20+
private static let nullLabel = String(localized: "(NULL)")
21+
private static let emptyLabel = String(localized: "(Empty)")
22+
23+
init(
24+
columnName: String,
25+
values: [ColumnDistinctValue],
26+
loadedRowCount: Int,
27+
initialFilter: ColumnValueFilter?,
28+
onApply: @escaping (ColumnValueFilter?) -> Void,
29+
onCancel: @escaping () -> Void
30+
) {
31+
self.columnName = columnName
32+
self.values = values
33+
self.loadedRowCount = loadedRowCount
34+
self.onApply = onApply
35+
self.onCancel = onCancel
36+
if let initialFilter {
37+
_checkedValues = State(initialValue: initialFilter.selectedValues)
38+
_nullChecked = State(initialValue: initialFilter.includesNull)
39+
} else {
40+
_checkedValues = State(initialValue: Set(values.filter { !$0.isNull }.map(\.display)))
41+
_nullChecked = State(initialValue: values.contains { $0.isNull })
42+
}
43+
}
44+
45+
var body: some View {
46+
VStack(alignment: .leading, spacing: 0) {
47+
header
48+
Divider()
49+
searchField
50+
selectAllRow
51+
Divider()
52+
valueList
53+
Divider()
54+
footer
55+
}
56+
.frame(width: 280)
57+
.frame(maxHeight: 420)
58+
}
59+
60+
private var header: some View {
61+
VStack(alignment: .leading, spacing: 2) {
62+
Text(columnName)
63+
.font(.headline)
64+
.lineLimit(1)
65+
.truncationMode(.middle)
66+
Text(loadedRowsCaption)
67+
.font(.caption)
68+
.foregroundStyle(.secondary)
69+
}
70+
.padding(.horizontal, 12)
71+
.padding(.top, 10)
72+
.padding(.bottom, 6)
73+
}
74+
75+
private var loadedRowsCaption: String {
76+
String(format: String(localized: "Values from %d loaded rows"), loadedRowCount)
77+
}
78+
79+
private var searchField: some View {
80+
HStack(spacing: 6) {
81+
Image(systemName: "magnifyingglass")
82+
.foregroundStyle(.secondary)
83+
TextField(String(localized: "Search values"), text: $searchText)
84+
.textFieldStyle(.plain)
85+
.focused($searchFocused)
86+
if !searchText.isEmpty {
87+
Button {
88+
searchText = ""
89+
} label: {
90+
Image(systemName: "xmark.circle.fill")
91+
.foregroundStyle(.secondary)
92+
}
93+
.buttonStyle(.plain)
94+
}
95+
}
96+
.padding(.horizontal, 8)
97+
.padding(.vertical, 5)
98+
.background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6))
99+
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(nsColor: .separatorColor)))
100+
.padding(.horizontal, 12)
101+
.padding(.bottom, 6)
102+
.onAppear { searchFocused = true }
103+
}
104+
105+
private var selectAllRow: some View {
106+
Toggle(isOn: Binding(get: { allSelected }, set: { setAll($0) })) {
107+
Text("Select All")
108+
.font(.callout)
109+
}
110+
.toggleStyle(.checkbox)
111+
.padding(.horizontal, 12)
112+
.padding(.bottom, 4)
113+
}
114+
115+
private var valueList: some View {
116+
ScrollView {
117+
LazyVStack(alignment: .leading, spacing: 0) {
118+
ForEach(filteredValues) { value in
119+
valueRow(value)
120+
}
121+
}
122+
.padding(.vertical, 2)
123+
}
124+
.frame(minHeight: 120)
125+
}
126+
127+
private func valueRow(_ value: ColumnDistinctValue) -> some View {
128+
HStack(spacing: 6) {
129+
Toggle(isOn: binding(for: value)) {
130+
Text(label(for: value))
131+
.lineLimit(1)
132+
.truncationMode(.tail)
133+
.foregroundStyle(value.isNull ? Color.secondary : Color.primary)
134+
}
135+
.toggleStyle(.checkbox)
136+
Spacer(minLength: 8)
137+
Text("\(value.count)")
138+
.font(.caption.monospacedDigit())
139+
.foregroundStyle(.secondary)
140+
}
141+
.padding(.horizontal, 12)
142+
.padding(.vertical, 2)
143+
.contentShape(Rectangle())
144+
}
145+
146+
private var footer: some View {
147+
HStack {
148+
Spacer()
149+
Button(role: .cancel) {
150+
onCancel()
151+
} label: {
152+
Text("Cancel")
153+
}
154+
.keyboardShortcut(.cancelAction)
155+
Button {
156+
apply()
157+
} label: {
158+
Text("Apply")
159+
}
160+
.keyboardShortcut(.defaultAction)
161+
.disabled(nothingSelected)
162+
}
163+
.padding(12)
164+
}
165+
166+
private var filteredValues: [ColumnDistinctValue] {
167+
guard !searchText.isEmpty else { return values }
168+
return values.filter { label(for: $0).localizedCaseInsensitiveContains(searchText) }
169+
}
170+
171+
private var allSelected: Bool {
172+
values.allSatisfy { $0.isNull ? nullChecked : checkedValues.contains($0.display) }
173+
}
174+
175+
private var nothingSelected: Bool {
176+
checkedValues.isEmpty && !nullChecked
177+
}
178+
179+
private func label(for value: ColumnDistinctValue) -> String {
180+
if value.isNull { return Self.nullLabel }
181+
return value.display.isEmpty ? Self.emptyLabel : value.display
182+
}
183+
184+
private func binding(for value: ColumnDistinctValue) -> Binding<Bool> {
185+
if value.isNull {
186+
return Binding(get: { nullChecked }, set: { nullChecked = $0 })
187+
}
188+
return Binding(
189+
get: { checkedValues.contains(value.display) },
190+
set: { isOn in
191+
if isOn {
192+
checkedValues.insert(value.display)
193+
} else {
194+
checkedValues.remove(value.display)
195+
}
196+
}
197+
)
198+
}
199+
200+
private func setAll(_ selected: Bool) {
201+
if selected {
202+
checkedValues = Set(values.filter { !$0.isNull }.map(\.display))
203+
nullChecked = values.contains { $0.isNull }
204+
} else {
205+
checkedValues = []
206+
nullChecked = false
207+
}
208+
}
209+
210+
private func apply() {
211+
if allSelected {
212+
onApply(nil)
213+
} else {
214+
onApply(ColumnValueFilter(selectedValues: checkedValues, includesNull: nullChecked))
215+
}
216+
}
217+
}

0 commit comments

Comments
 (0)