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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

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

Expand Down
56 changes: 56 additions & 0 deletions TablePro/Models/Query/GridValueFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// GridValueFilter.swift
// TablePro
//

import Foundation

struct ColumnValueFilter: Equatable {
var selectedValues: Set<String>
var includesNull: Bool

var hidesEverything: Bool { selectedValues.isEmpty && !includesNull }
}

struct ColumnDistinctValue: Identifiable, Equatable {
let display: String
let isNull: Bool
let count: Int

var id: String { isNull ? "\u{0}<null>" : "v:\(display)" }
}

struct GridValueFilterState: Equatable {
private(set) var filters: [Int: ColumnValueFilter] = [:]
private(set) var columnNames: [Int: String] = [:]

var isActive: Bool { !filters.isEmpty }
var activeColumnCount: Int { filters.count }
var activeColumns: Set<Int> { Set(filters.keys) }

func isActive(column dataIndex: Int) -> Bool { filters[dataIndex] != nil }

func filter(forColumn dataIndex: Int) -> ColumnValueFilter? { filters[dataIndex] }

mutating func set(_ filter: ColumnValueFilter, columnName: String, forColumn dataIndex: Int) {
filters[dataIndex] = filter
columnNames[dataIndex] = columnName
}

mutating func clear(column dataIndex: Int) {
filters.removeValue(forKey: dataIndex)
columnNames.removeValue(forKey: dataIndex)
}

mutating func clearAll() {
filters.removeAll()
columnNames.removeAll()
}

mutating func prune(againstColumns columns: [String]) {
for (dataIndex, name) in columnNames where dataIndex >= columns.count || columns[dataIndex] != name {
filters.removeValue(forKey: dataIndex)
columnNames.removeValue(forKey: dataIndex)
}
}
}
197 changes: 197 additions & 0 deletions TablePro/Views/Results/ColumnValueFilterPopover.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
//
// ColumnValueFilterPopover.swift
// TablePro
//

import SwiftUI

struct ColumnValueFilterPopover: View {
let columnName: String
let values: [ColumnDistinctValue]
let loadedRowCount: Int
let onApply: (ColumnValueFilter?) -> Void
let onCancel: () -> Void

@State private var checkedValues: Set<String>
@State private var nullChecked: Bool
@State private var searchText: String = ""

private static let nullLabel = String(localized: "(NULL)")
private static let emptyLabel = String(localized: "(Empty)")

init(
columnName: String,
values: [ColumnDistinctValue],
loadedRowCount: Int,
initialFilter: ColumnValueFilter?,
onApply: @escaping (ColumnValueFilter?) -> Void,
onCancel: @escaping () -> Void
) {
self.columnName = columnName
self.values = values
self.loadedRowCount = loadedRowCount
self.onApply = onApply
self.onCancel = onCancel
if let initialFilter {
_checkedValues = State(initialValue: initialFilter.selectedValues)
_nullChecked = State(initialValue: initialFilter.includesNull)
} else {
_checkedValues = State(initialValue: Set(values.filter { !$0.isNull }.map(\.display)))
_nullChecked = State(initialValue: values.contains { $0.isNull })
}
}

var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
controls
Divider()
valueList
Divider()
footer
}
.frame(width: 260)
}

private var header: some View {
VStack(alignment: .leading, spacing: 1) {
Text(columnName)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
Text(loadedRowsCaption)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 12)
.padding(.bottom, 8)
}

private var loadedRowsCaption: String {
String(format: String(localized: "Values from %d loaded rows"), loadedRowCount)
}

private var controls: some View {
VStack(spacing: 8) {
NativeSearchField(
text: $searchText,
placeholder: String(localized: "Search values"),
onSubmit: { apply() },
focusOnAppear: true,
accessibilityIdentifier: "value-filter-search"
)
HStack(spacing: 6) {
TristateCheckbox(state: selectAllState) { toggleSelectAll() }
Text("Select All")
Spacer()
}
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
}

private var valueList: some View {
List {
ForEach(filteredValues) { value in
Toggle(isOn: binding(for: value)) {
HStack(spacing: 8) {
Text(label(for: value))
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(value.isNull ? Color.secondary : Color.primary)
Spacer(minLength: 8)
Text("\(value.count)")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.toggleStyle(.checkbox)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.frame(height: 200)
}

private var footer: some View {
HStack {
Spacer()
Button(role: .cancel) {
onCancel()
} label: {
Text("Cancel")
}
.keyboardShortcut(.cancelAction)
Button {
apply()
} label: {
Text("Apply")
}
.keyboardShortcut(.defaultAction)
.disabled(nothingSelected)
}
.padding(14)
}

private var filteredValues: [ColumnDistinctValue] {
guard !searchText.isEmpty else { return values }
return values.filter { label(for: $0).localizedCaseInsensitiveContains(searchText) }
}

private var selectAllState: TristateCheckbox.State {
let selected = values.filter { $0.isNull ? nullChecked : checkedValues.contains($0.display) }.count
if selected == 0 { return .unchecked }
if selected == values.count { return .checked }
return .mixed
}

private var nothingSelected: Bool {
checkedValues.isEmpty && !nullChecked
}

private func label(for value: ColumnDistinctValue) -> String {
if value.isNull { return Self.nullLabel }
return value.display.isEmpty ? Self.emptyLabel : value.display
}

private func binding(for value: ColumnDistinctValue) -> Binding<Bool> {
if value.isNull {
return Binding(get: { nullChecked }, set: { nullChecked = $0 })
}
return Binding(
get: { checkedValues.contains(value.display) },
set: { isOn in
if isOn {
checkedValues.insert(value.display)
} else {
checkedValues.remove(value.display)
}
}
)
}

private func toggleSelectAll() {
setAll(selectAllState != .checked)
}

private func setAll(_ selected: Bool) {
if selected {
checkedValues = Set(values.filter { !$0.isNull }.map(\.display))
nullChecked = values.contains { $0.isNull }
} else {
checkedValues = []
nullChecked = false
}
}

private func apply() {
if selectAllState == .checked {
onApply(nil)
} else {
onApply(ColumnValueFilter(selectedValues: checkedValues, includesNull: nullChecked))
}
}
}
Loading
Loading