Skip to content

Commit c2274a8

Browse files
authored
feat(datagrid): apply filters individually (enable, disable, solo) (#1561) (#1562)
* feat(datagrid): apply filters individually with per-row enable, disable, and solo (#1561) * refactor(datagrid): address review (solo-button a11y state, drop what-comments, cover solo edge cases)
1 parent 4726e55 commit c2274a8

18 files changed

Lines changed: 507 additions & 197 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+
- Each filter row has a checkbox to turn it on or off and an Apply button to filter by just that row. The main Apply runs every active filter, and disabled filters stay in the panel for later. (#1561)
1213
- Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import.
1314

1415
### Changed

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,9 @@ Missing a case produces a wrong "{Language} Query" title on the first frame.
172172
| User preferences | UserDefaults | `AppSettingsStorage` / `AppSettingsManager` |
173173
| Query history | SQLite FTS5 | `QueryHistoryStorage` |
174174
| Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` |
175-
| Filter presets | UserDefaults | `FilterSettingsStorage` |
176-
| Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) |
175+
| Filter defaults | UserDefaults | `FilterSettingsStorage` (default column/operator, panel state) |
176+
| Filter presets | UserDefaults | `FilterPresetStorage` |
177+
| Per-table filters | JSON files | `FilterSettingsStorage` (one file per connection + database + schema + table; saves the valid working set, each row's enabled flag included) |
177178
| Favorite tables | UserDefaults | `FavoriteTablesStorage` (per connection + database + schema; iCloud-synced) |
178179

179180
### Logging & Debugging

TablePro/Core/Coordinators/FilterCoordinator.swift

Lines changed: 18 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,6 @@ final class FilterCoordinator {
154154
}
155155

156156
newFilter.filterOperator = settings.defaultOperator.toFilterOperator()
157-
newFilter.isSelected = true
158157

159158
mutateSelectedTabFilterState { state in
160159
state.filters.append(newFilter)
@@ -166,7 +165,6 @@ final class FilterCoordinator {
166165
var newFilter = TableFilter()
167166
newFilter.columnName = columnName
168167
newFilter.filterOperator = settings.defaultOperator.toFilterOperator()
169-
newFilter.isSelected = true
170168

171169
mutateSelectedTabFilterState { state in
172170
state.filters.append(newFilter)
@@ -179,7 +177,7 @@ final class FilterCoordinator {
179177
func setFKFilter(_ filter: TableFilter) {
180178
mutateSelectedTabFilterState { state in
181179
state.filters = [filter]
182-
state.appliedFilters = [filter]
180+
state.commit = .all
183181
state.isVisible = true
184182
state.filterLogicMode = .and
185183
}
@@ -192,7 +190,6 @@ final class FilterCoordinator {
192190
filterOperator: filter.filterOperator,
193191
value: filter.value,
194192
secondValue: filter.secondValue,
195-
isSelected: true,
196193
isEnabled: filter.isEnabled,
197194
rawSQL: filter.rawSQL
198195
)
@@ -208,7 +205,9 @@ final class FilterCoordinator {
208205
func removeFilter(_ filter: TableFilter) {
209206
mutateSelectedTabFilterState { state in
210207
state.filters.removeAll { $0.id == filter.id }
211-
state.appliedFilters.removeAll { $0.id == filter.id }
208+
if case .solo(let id) = state.commit, id == filter.id {
209+
state.commit = nil
210+
}
212211
}
213212
}
214213

@@ -279,28 +278,29 @@ final class FilterCoordinator {
279278
guard filter.isValid else { return }
280279
mutateSelectedTabFilterState { state in
281280
state.filters = [filter]
282-
state.appliedFilters = [filter]
281+
state.commit = .all
283282
state.isVisible = true
284283
}
285284
}
286285

287-
func applySelectedFilters() {
286+
func applyAllFilters() {
288287
mutateSelectedTabFilterState { state in
289-
state.appliedFilters = state.filters.filter { $0.isSelected && $0.isValid }
288+
state.commit = .all
290289
}
291290
saveLastFiltersForActiveTable()
292291
}
293292

294-
func applyAllFilters() {
293+
func applySoloFilter(_ filter: TableFilter) {
294+
guard filter.isValid else { return }
295295
mutateSelectedTabFilterState { state in
296-
state.appliedFilters = state.filters.filter { $0.isEnabled && $0.isValid }
296+
state.commit = .solo(filter.id)
297297
}
298298
saveLastFiltersForActiveTable()
299299
}
300300

301301
func clearAppliedFilters() {
302302
mutateSelectedTabFilterState { state in
303-
state.appliedFilters = []
303+
state.commit = nil
304304
}
305305
}
306306

@@ -330,31 +330,13 @@ final class FilterCoordinator {
330330
}
331331
}
332332

333-
// MARK: - Selection
334-
335-
func selectAllFilters(_ selected: Bool) {
336-
mutateSelectedTabFilterState { state in
337-
for index in 0..<state.filters.count {
338-
state.filters[index].isSelected = selected
339-
}
340-
}
341-
}
342-
343-
func toggleFilterSelection(_ filter: TableFilter) {
344-
mutateSelectedTabFilterState { state in
345-
if let index = state.filters.firstIndex(where: { $0.id == filter.id }) {
346-
state.filters[index].isSelected.toggle()
347-
}
348-
}
349-
}
350-
351333
// MARK: - Persistence
352334

353335
func saveLastFiltersForActiveTable() {
354336
guard let tab = parent.tabManager.selectedTab,
355337
let tableName = tab.tableContext.tableName else { return }
356338
FilterSettingsStorage.shared.saveLastFilters(
357-
tab.filterState.appliedFilters,
339+
tab.filterState.filters.filter(\.isValid),
358340
for: tableName,
359341
connectionId: parent.connectionId,
360342
databaseName: tab.tableContext.databaseName,
@@ -365,7 +347,7 @@ final class FilterCoordinator {
365347
func saveLastFilters(for tableName: String) {
366348
guard let tab = parent.tabManager.selectedTab else { return }
367349
FilterSettingsStorage.shared.saveLastFilters(
368-
tab.filterState.appliedFilters,
350+
tab.filterState.filters.filter(\.isValid),
369351
for: tableName,
370352
connectionId: parent.connectionId,
371353
databaseName: tab.tableContext.databaseName,
@@ -412,15 +394,15 @@ final class FilterCoordinator {
412394
switch panelState {
413395
case .alwaysHide:
414396
state.filters = []
415-
state.appliedFilters = []
397+
state.commit = nil
416398
state.isVisible = false
417399
case .alwaysShow:
418400
state.filters = saved
419-
state.appliedFilters = saved
401+
state.commit = .all
420402
state.isVisible = true
421403
case .restoreLast:
422404
state.filters = saved
423-
state.appliedFilters = saved
405+
state.commit = .all
424406
state.isVisible = !saved.isEmpty
425407
}
426408
return state
@@ -429,7 +411,7 @@ final class FilterCoordinator {
429411
func clearFilterState() {
430412
mutateSelectedTabFilterState { state in
431413
state.filters = []
432-
state.appliedFilters = []
414+
state.commit = nil
433415
}
434416
}
435417

@@ -475,16 +457,7 @@ final class FilterCoordinator {
475457
}
476458

477459
private func filtersForPreview(in state: TabFilterState) -> [TableFilter] {
478-
var valid: [TableFilter] = []
479-
var selectedValid: [TableFilter] = []
480-
for filter in state.filters where filter.isEnabled && filter.isValid {
481-
valid.append(filter)
482-
if filter.isSelected { selectedValid.append(filter) }
483-
}
484-
if selectedValid.count == valid.count || selectedValid.isEmpty {
485-
return valid
486-
}
487-
return selectedValid
460+
state.filters.filter { $0.isEnabled && $0.isValid }
488461
}
489462

490463
// MARK: - Private

TablePro/Models/Database/TableFilter.swift

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,12 @@ enum FilterOperator: String, CaseIterable, Identifiable, Codable {
9494
/// Represents a single table filter condition
9595
struct TableFilter: Identifiable, Equatable, Hashable, Codable {
9696
let id: UUID
97-
var columnName: String // Column to filter on, or "__RAW__" for raw SQL
97+
var columnName: String
9898
var filterOperator: FilterOperator
9999
var value: String
100-
var secondValue: String? // For BETWEEN operator
101-
var isSelected: Bool // For multi-select apply
102-
var isEnabled: Bool // Whether filter is active
103-
var rawSQL: String? // For raw SQL mode
100+
var secondValue: String?
101+
var isEnabled: Bool
102+
var rawSQL: String?
104103

105104
/// Special column name for raw SQL mode
106105
static let rawSQLColumn = "__RAW__"
@@ -111,7 +110,6 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable {
111110
filterOperator: FilterOperator = .equal,
112111
value: String = "",
113112
secondValue: String? = nil,
114-
isSelected: Bool = false,
115113
isEnabled: Bool = true,
116114
rawSQL: String? = nil
117115
) {
@@ -120,7 +118,6 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable {
120118
self.filterOperator = filterOperator
121119
self.value = value
122120
self.secondValue = secondValue
123-
self.isSelected = isSelected
124121
self.isEnabled = isEnabled
125122
self.rawSQL = rawSQL
126123
}
@@ -187,19 +184,31 @@ extension TableFilter {
187184
/// Stores per-tab filter state (preserves filters when switching tabs)
188185
struct TabFilterState: Equatable, Hashable, Codable {
189186
var filters: [TableFilter]
190-
var appliedFilters: [TableFilter]
187+
var commit: FilterCommit?
191188
var isVisible: Bool
192189
var filterLogicMode: FilterLogicMode
193190

194191
init(isVisible: Bool = false) {
195192
self.filters = []
196-
self.appliedFilters = []
193+
self.commit = nil
197194
self.isVisible = isVisible
198195
self.filterLogicMode = .and
199196
}
200197

201-
var hasChanges: Bool {
202-
!filters.isEmpty || !appliedFilters.isEmpty
198+
var appliedFilters: [TableFilter] {
199+
guard let commit else { return [] }
200+
return Self.resolve(commit, in: filters)
201+
}
202+
203+
static func resolve(_ commit: FilterCommit, in filters: [TableFilter]) -> [TableFilter] {
204+
switch commit {
205+
case .all:
206+
return filters.filter { $0.isEnabled && $0.isValid }
207+
case .solo(let id):
208+
guard var match = filters.first(where: { $0.id == id }), match.isValid else { return [] }
209+
match.isEnabled = true
210+
return [match]
211+
}
203212
}
204213

205214
var hasAppliedFilters: Bool {

TablePro/Models/UI/FilterState.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ enum FilterLogicMode: String, Codable {
1414
}
1515
}
1616

17+
enum FilterCommit: Codable, Equatable, Hashable {
18+
case all
19+
case solo(UUID)
20+
}
21+
1722
extension TabFilterState {
18-
init(filters: [TableFilter], appliedFilters: [TableFilter], isVisible: Bool, filterLogicMode: FilterLogicMode) {
23+
init(filters: [TableFilter], commit: FilterCommit?, isVisible: Bool, filterLogicMode: FilterLogicMode) {
1924
self.filters = filters
20-
self.appliedFilters = appliedFilters
25+
self.commit = commit
2126
self.isVisible = isVisible
2227
self.filterLogicMode = filterLogicMode
2328
}

0 commit comments

Comments
 (0)