From f4785f30d7641c3b818e2a3717c06781c92ba9d0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 3 Jun 2026 02:10:41 +0700 Subject: [PATCH 1/2] feat(datagrid): apply filters individually with per-row enable, disable, and solo (#1561) --- CHANGELOG.md | 1 + CLAUDE.md | 5 +- .../Core/Coordinators/FilterCoordinator.swift | 63 +++----- TablePro/Models/Database/TableFilter.swift | 23 ++- TablePro/Models/UI/FilterState.swift | 9 +- TablePro/Resources/Localizable.xcstrings | 134 ++++++++++++++++++ TablePro/Views/Filter/FilterPanelView.swift | 59 +++++--- TablePro/Views/Filter/FilterRowView.swift | 61 ++++++-- .../MainContentCoordinator+FKNavigation.swift | 2 +- .../MainContentCoordinator+FilterState.swift | 16 +-- .../MainContentCoordinator+URLFilter.swift | 2 - .../Core/Coordinators/RowCountPlanTests.swift | 3 +- .../Database/FilterSQLGeneratorTests.swift | 90 ++---------- TableProTests/Helpers/TestFixtures.swift | 4 +- .../Models/TabFilterStateTests.swift | 77 ++++++++++ .../Views/Main/FilterRestoreTests.swift | 16 +++ ...MainContentCoordinatorTabSwitchTests.swift | 84 ++++++++++- docs/features/filtering.mdx | 8 +- 18 files changed, 464 insertions(+), 193 deletions(-) create mode 100644 TableProTests/Models/TabFilterStateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e806bffc8..72c6aa956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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) - 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. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 1b5be7bbf..e8a383913 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,8 +172,9 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | User preferences | UserDefaults | `AppSettingsStorage` / `AppSettingsManager` | | Query history | SQLite FTS5 | `QueryHistoryStorage` | | Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | -| Filter presets | UserDefaults | `FilterSettingsStorage` | -| Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) | +| Filter defaults | UserDefaults | `FilterSettingsStorage` (default column/operator, panel state) | +| Filter presets | UserDefaults | `FilterPresetStorage` | +| Per-table filters | JSON files | `FilterSettingsStorage` (one file per connection + database + schema + table; saves the valid working set, each row's enabled flag included) | | Favorite tables | UserDefaults | `FavoriteTablesStorage` (per connection + database + schema; iCloud-synced) | ### Logging & Debugging diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index d1aa38fab..5b1ca7c39 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -154,7 +154,6 @@ final class FilterCoordinator { } newFilter.filterOperator = settings.defaultOperator.toFilterOperator() - newFilter.isSelected = true mutateSelectedTabFilterState { state in state.filters.append(newFilter) @@ -166,7 +165,6 @@ final class FilterCoordinator { var newFilter = TableFilter() newFilter.columnName = columnName newFilter.filterOperator = settings.defaultOperator.toFilterOperator() - newFilter.isSelected = true mutateSelectedTabFilterState { state in state.filters.append(newFilter) @@ -179,7 +177,7 @@ final class FilterCoordinator { func setFKFilter(_ filter: TableFilter) { mutateSelectedTabFilterState { state in state.filters = [filter] - state.appliedFilters = [filter] + state.commit = .all state.isVisible = true state.filterLogicMode = .and } @@ -192,7 +190,6 @@ final class FilterCoordinator { filterOperator: filter.filterOperator, value: filter.value, secondValue: filter.secondValue, - isSelected: true, isEnabled: filter.isEnabled, rawSQL: filter.rawSQL ) @@ -208,7 +205,9 @@ final class FilterCoordinator { func removeFilter(_ filter: TableFilter) { mutateSelectedTabFilterState { state in state.filters.removeAll { $0.id == filter.id } - state.appliedFilters.removeAll { $0.id == filter.id } + if case .solo(let id) = state.commit, id == filter.id { + state.commit = nil + } } } @@ -279,28 +278,29 @@ final class FilterCoordinator { guard filter.isValid else { return } mutateSelectedTabFilterState { state in state.filters = [filter] - state.appliedFilters = [filter] + state.commit = .all state.isVisible = true } } - func applySelectedFilters() { + func applyAllFilters() { mutateSelectedTabFilterState { state in - state.appliedFilters = state.filters.filter { $0.isSelected && $0.isValid } + state.commit = .all } saveLastFiltersForActiveTable() } - func applyAllFilters() { + func applySoloFilter(_ filter: TableFilter) { + guard filter.isValid else { return } mutateSelectedTabFilterState { state in - state.appliedFilters = state.filters.filter { $0.isEnabled && $0.isValid } + state.commit = .solo(filter.id) } saveLastFiltersForActiveTable() } func clearAppliedFilters() { mutateSelectedTabFilterState { state in - state.appliedFilters = [] + state.commit = nil } } @@ -330,31 +330,13 @@ final class FilterCoordinator { } } - // MARK: - Selection - - func selectAllFilters(_ selected: Bool) { - mutateSelectedTabFilterState { state in - for index in 0.. [TableFilter] { - var valid: [TableFilter] = [] - var selectedValid: [TableFilter] = [] - for filter in state.filters where filter.isEnabled && filter.isValid { - valid.append(filter) - if filter.isSelected { selectedValid.append(filter) } - } - if selectedValid.count == valid.count || selectedValid.isEmpty { - return valid - } - return selectedValid + state.filters.filter { $0.isEnabled && $0.isValid } } // MARK: - Private diff --git a/TablePro/Models/Database/TableFilter.swift b/TablePro/Models/Database/TableFilter.swift index c57ede666..bcb391f57 100644 --- a/TablePro/Models/Database/TableFilter.swift +++ b/TablePro/Models/Database/TableFilter.swift @@ -98,7 +98,6 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable { var filterOperator: FilterOperator var value: String var secondValue: String? // For BETWEEN operator - var isSelected: Bool // For multi-select apply var isEnabled: Bool // Whether filter is active var rawSQL: String? // For raw SQL mode @@ -111,7 +110,6 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable { filterOperator: FilterOperator = .equal, value: String = "", secondValue: String? = nil, - isSelected: Bool = false, isEnabled: Bool = true, rawSQL: String? = nil ) { @@ -120,7 +118,6 @@ struct TableFilter: Identifiable, Equatable, Hashable, Codable { self.filterOperator = filterOperator self.value = value self.secondValue = secondValue - self.isSelected = isSelected self.isEnabled = isEnabled self.rawSQL = rawSQL } @@ -187,19 +184,31 @@ extension TableFilter { /// Stores per-tab filter state (preserves filters when switching tabs) struct TabFilterState: Equatable, Hashable, Codable { var filters: [TableFilter] - var appliedFilters: [TableFilter] + var commit: FilterCommit? var isVisible: Bool var filterLogicMode: FilterLogicMode init(isVisible: Bool = false) { self.filters = [] - self.appliedFilters = [] + self.commit = nil self.isVisible = isVisible self.filterLogicMode = .and } - var hasChanges: Bool { - !filters.isEmpty || !appliedFilters.isEmpty + var appliedFilters: [TableFilter] { + guard let commit else { return [] } + return Self.resolve(commit, in: filters) + } + + static func resolve(_ commit: FilterCommit, in filters: [TableFilter]) -> [TableFilter] { + switch commit { + case .all: + return filters.filter { $0.isEnabled && $0.isValid } + case .solo(let id): + guard var match = filters.first(where: { $0.id == id }), match.isValid else { return [] } + match.isEnabled = true + return [match] + } } var hasAppliedFilters: Bool { diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index dc7a9dce7..529b028bf 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -14,10 +14,15 @@ enum FilterLogicMode: String, Codable { } } +enum FilterCommit: Codable, Equatable, Hashable { + case all + case solo(UUID) +} + extension TabFilterState { - init(filters: [TableFilter], appliedFilters: [TableFilter], isVisible: Bool, filterLogicMode: FilterLogicMode) { + init(filters: [TableFilter], commit: FilterCommit?, isVisible: Bool, filterLogicMode: FilterLogicMode) { self.filters = filters - self.appliedFilters = appliedFilters + self.commit = commit self.isVisible = isVisible self.filterLogicMode = filterLogicMode } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 75c44ef5d..4bee5d102 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -93,6 +93,7 @@ }, "—" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -995,6 +996,7 @@ } }, "%@ is already assigned to \"%@\". Reassigning will remove it from that action." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1022,6 +1024,16 @@ } } }, + "%@ is already used by \"%@\" in %@. Reassigning removes it from that action." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ is already used by \"%2$@\" in %3$@. Reassigning removes it from that action." + } + } + } + }, "%@ is required" : { }, @@ -1682,6 +1694,9 @@ }, "%d plugins could not be loaded." : { + }, + "%d plugins need an update to load." : { + }, "%d rows" : { "localizations" : { @@ -2885,6 +2900,9 @@ }, "1 plugin could not be loaded." : { + }, + "1 plugin needs an update to load." : { + }, "1 year" : { "localizations" : { @@ -3606,6 +3624,9 @@ } } } + }, + "A newer TablePro is required to load this plugin." : { + }, "A newer version of TablePro is required for this plugin. Update TablePro to keep using it." : { @@ -6029,6 +6050,7 @@ } }, "AND" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -6235,6 +6257,9 @@ } } } + }, + "App" : { + }, "Appearance" : { "localizations" : { @@ -6280,6 +6305,9 @@ } } } + }, + "Applied" : { + }, "Applied when opening a table. Click a column header to override." : { @@ -6305,6 +6333,9 @@ } } } + }, + "Apply active filters (Cmd+Return)" : { + }, "Apply All" : { "extractionState" : "stale", @@ -6396,6 +6427,7 @@ } }, "Apply filters" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -6416,6 +6448,9 @@ } } } + }, + "Apply only this filter" : { + }, "Apply Schema Changes" : { @@ -8165,6 +8200,9 @@ }, "CA certificate is required for verification modes" : { + }, + "CA certificate not found: %@" : { + }, "Cache Hit Ratio" : { "localizations" : { @@ -9844,6 +9882,9 @@ } } } + }, + "Client certificate not found: %@" : { + }, "Client Certificates" : { @@ -9898,6 +9939,9 @@ }, "Client key is required when client certificate is set" : { + }, + "Client key not found: %@" : { + }, "Client Secret" : { @@ -14558,6 +14602,9 @@ } } } + }, + "Database type \"%@\" is not installed" : { + }, "Database Type:" : { "extractionState" : "stale", @@ -15619,6 +15666,9 @@ } } } + }, + "Delete Line" : { + }, "Delete Preset" : { "localizations" : { @@ -17194,6 +17244,9 @@ } } } + }, + "Duplicate Line" : { + }, "Duplicate Row" : { "localizations" : { @@ -17776,6 +17829,9 @@ } } } + }, + "Editor & Query" : { + }, "Editor Font" : { "localizations" : { @@ -17921,6 +17977,9 @@ }, "Enable Cloudflare Tunnel" : { + }, + "Enable filter" : { + }, "Enable inline suggestions" : { "extractionState" : "stale", @@ -21606,6 +21665,7 @@ } }, "File" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -21827,6 +21887,9 @@ }, "Filter activity" : { + }, + "Filter by only this row" : { + }, "Filter column" : { "localizations" : { @@ -22162,6 +22225,9 @@ } } } + }, + "Filtering by only this row" : { + }, "Filters" : { "localizations" : { @@ -22184,6 +22250,15 @@ } } } + }, + "Find" : { + + }, + "Find Next" : { + + }, + "Find Previous" : { + }, "Find..." : { "localizations" : { @@ -24559,6 +24634,9 @@ }, "In-memory data store and cache" : { + }, + "Inactive" : { + }, "Include approximate row counts (default false)" : { @@ -24763,6 +24841,9 @@ } } } + }, + "Include this filter when applying" : { + }, "Incompatible plugin version" : { "extractionState" : "stale", @@ -24852,6 +24933,9 @@ } } } + }, + "Indent" : { + }, "INDEX" : { "extractionState" : "stale", @@ -26259,6 +26343,9 @@ }, "Jump host configuration is invalid" : { + }, + "Jump host key not found: %@" : { + }, "Jump Hosts" : { "localizations" : { @@ -28356,8 +28443,12 @@ } } } + }, + "Match all" : { + }, "Match ALL filters (AND) or ANY filter (OR)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -28378,6 +28469,12 @@ } } } + }, + "Match all filters or any filter" : { + + }, + "Match any" : { + }, "matches regex" : { "localizations" : { @@ -29380,6 +29477,12 @@ } } } + }, + "Move Line Down" : { + + }, + "Move Line Up" : { + }, "Move to" : { "localizations" : { @@ -29713,6 +29816,9 @@ } } } + }, + "Navigation" : { + }, "Nearest" : { "extractionState" : "stale", @@ -33724,6 +33830,7 @@ } }, "OR" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -33845,6 +33952,9 @@ }, "Outcome: %@" : { + }, + "Outdent" : { + }, "Output truncated for display" : { @@ -39138,6 +39248,9 @@ } } } + }, + "Reserved Shortcut" : { + }, "Reset" : { "localizations" : { @@ -39235,6 +39348,9 @@ }, "Reset Sample Database..." : { + }, + "Reset to default" : { + }, "Reset to Defaults" : { "localizations" : { @@ -42047,6 +42163,9 @@ } } } + }, + "Select Tab %d" : { + }, "Select Tab %lld" : { "localizations" : { @@ -43039,6 +43158,9 @@ } } } + }, + "Show Completions" : { + }, "Show DDL" : { @@ -44739,6 +44861,9 @@ }, "SSH port must be between 1 and 65535" : { + }, + "SSH private key not found: %@" : { + }, "SSH private key rejected. Check the key file or passphrase." : { @@ -48692,6 +48817,9 @@ } } } + }, + "This shortcut is reserved for \"%@\" and cannot be assigned." : { + }, "This SQL query failed with an error. Please fix it.\n\nQuery:\n```sql\n%@\n```\n\nError: %@" : { "extractionState" : "stale", @@ -49347,6 +49475,9 @@ } } } + }, + "Toggle Comment" : { + }, "Toggle filters" : { "extractionState" : "stale", @@ -51220,6 +51351,9 @@ } } } + }, + "Update TablePro" : { + }, "Update to v%@" : { "localizations" : { diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 4677fcf4a..7c413aa74 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -22,8 +22,8 @@ struct FilterPanelView: View { @State private var focusedFilterId: UUID? @State private var rawSQLCompletionProvider: RawSQLFilterCompletionProvider? - private let estimatedFilterRowHeight: CGFloat = 32 private let maxFilterListHeight: CGFloat = 200 + @State private var filterRowsHeight: CGFloat = 0 private var filterState: TabFilterState { coordinator.selectedTabFilterState @@ -64,6 +64,7 @@ struct FilterPanelView: View { .sheet(isPresented: $showSQLSheet) { SQLPreviewSheet(sql: generatedSQL) } + .onPreferenceChange(FilterRowsHeightKey.self) { filterRowsHeight = $0 } } private var filterHeader: some View { @@ -73,13 +74,14 @@ struct FilterPanelView: View { if filterState.filters.count > 1 { Picker("", selection: coordinator.filterLogicModeBinding()) { - Text("AND").tag(FilterLogicMode.and) - Text("OR").tag(FilterLogicMode.or) + Text("Match all").tag(FilterLogicMode.and) + Text("Match any").tag(FilterLogicMode.or) } - .pickerStyle(.segmented) - .frame(width: 80) + .pickerStyle(.menu) + .fixedSize() + .labelsHidden() .accessibilityLabel(String(localized: "Filter logic mode")) - .help(String(localized: "Match ALL filters (AND) or ANY filter (OR)")) + .help(String(localized: "Match all filters or any filter")) } Spacer() @@ -101,9 +103,9 @@ struct FilterPanelView: View { } .buttonStyle(.borderedProminent) .controlSize(.small) - .keyboardShortcut(.defaultAction) - .disabled(validFilterCount == 0) - .help(String(localized: "Apply filters")) + .keyboardShortcut(.return, modifiers: .command) + .disabled(enabledValidFilterCount == 0 && !filterState.hasAppliedFilters) + .help(String(localized: "Apply active filters (Cmd+Return)")) } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -196,6 +198,7 @@ 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 @@ -211,6 +214,7 @@ struct FilterPanelView: View { coordinator.focusActiveGrid() } }, + onApply: { applySoloFilter(filter) }, onSubmit: { applyAllValidFilters() }, onCancel: { closePanelAndFocusGrid() }, focusedFilterId: $focusedFilterId @@ -220,21 +224,28 @@ struct FilterPanelView: View { .padding(.vertical, 4) } + private var measuredFilterRows: some View { + filterRows.background( + GeometryReader { proxy in + Color.clear.preference(key: FilterRowsHeightKey.self, value: proxy.size.height) + } + ) + } + @ViewBuilder private var filterList: some View { - let estimatedHeight = CGFloat(filterState.filters.count) * estimatedFilterRowHeight + 8 - if estimatedHeight > maxFilterListHeight { + if filterRowsHeight > maxFilterListHeight { ScrollView { - filterRows + measuredFilterRows } - .frame(maxHeight: maxFilterListHeight) + .frame(height: maxFilterListHeight) } else { - filterRows + measuredFilterRows } } - private var validFilterCount: Int { - filterState.filters.count(where: \.isValid) + private var enabledValidFilterCount: Int { + filterState.filters.count { $0.isEnabled && $0.isValid } } private func presetColumnsMatch(_ preset: FilterPreset) -> Bool { @@ -248,14 +259,19 @@ struct FilterPanelView: View { coordinator.focusActiveGrid() } + private func applySoloFilter(_ filter: TableFilter) { + coordinator.applySoloFilter(filter) + onApply(coordinator.selectedTabFilterState.appliedFilters) + coordinator.focusActiveGrid() + } + private func closePanelAndFocusGrid() { coordinator.closeFilterPanel() coordinator.focusActiveGrid() } private var isSQLDialect: Bool { - let langName = PluginManager.shared.queryLanguageName(for: databaseType) - return langName == "SQL" || langName == "CQL" || langName == "PartiQL" + PluginManager.shared.sqlDialect(for: databaseType) != nil } private func completionItems() -> [String] { @@ -280,3 +296,10 @@ struct FilterPanelView: View { ) } } + +private struct FilterRowsHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index e44cd72f1..4a46ddbf3 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -11,13 +11,17 @@ 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 + let onApply: () -> Void let onSubmit: () -> Void let onCancel: () -> Void @Binding var focusedFilterId: UUID? + private let rowButtonGlyphSize: CGFloat = 14 + private var pickerEligibleOperators: Set { [.equal, .notEqual] } @@ -38,13 +42,18 @@ struct FilterRowView: View { var body: some View { HStack(spacing: 4) { - columnPicker + enabledToggle - if !filter.isRawSQL { - operatorPicker - } + Group { + columnPicker - valueFields + if !filter.isRawSQL { + operatorPicker + } + + valueFields + } + .opacity(filter.isEnabled ? 1 : 0.5) rowButtons } @@ -53,6 +62,15 @@ struct FilterRowView: View { .contextMenu { rowContextMenu } } + private var enabledToggle: some View { + Toggle("", isOn: $filter.isEnabled) + .toggleStyle(.checkbox) + .labelsHidden() + .accessibilityLabel(String(localized: "Enable filter")) + .accessibilityValue(filter.isEnabled ? String(localized: "Active") : String(localized: "Inactive")) + .help(String(localized: "Include this filter when applying")) + } + private var columnPicker: some View { Picker("", selection: $filter.columnName) { Text("Raw SQL").tag(TableFilter.rawSQLColumn) @@ -138,29 +156,45 @@ struct FilterRowView: View { .onSubmit { onSubmit() } } } else { - Text("—") - .font(.callout) - .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, alignment: .leading) + Spacer(minLength: 0) + } + } + + @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")) + .help(isApplied + ? String(localized: "Filtering by only this row") + : String(localized: "Filter by only this row")) + Button(action: onAdd) { Image(systemName: "plus") - .frame(width: 24, height: 24) + .frame(width: rowButtonGlyphSize, height: rowButtonGlyphSize) } - .buttonStyle(.borderless) + .buttonStyle(.bordered) .controlSize(.small) .accessibilityLabel(String(localized: "Add filter")) .help(String(localized: "Add filter row")) Button(action: onRemove) { Image(systemName: "minus") - .frame(width: 24, height: 24) + .frame(width: rowButtonGlyphSize, height: rowButtonGlyphSize) } - .buttonStyle(.borderless) + .buttonStyle(.bordered) .controlSize(.small) .accessibilityLabel(String(localized: "Remove filter")) .help(String(localized: "Remove filter row")) @@ -207,7 +241,6 @@ struct FilterRowView: View { .frame(minWidth: 100) .labelsHidden() .accessibilityLabel(String(localized: "Filter value")) - .onChange(of: filter.value) { _, _ in onSubmit() } } private struct OperatorMenuLabel: View { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 5d64cb897..367ae6d25 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -108,7 +108,7 @@ extension MainContentCoordinator { ) -> EditorTabPayload { let fkFilterState = TabFilterState( filters: [filter], - appliedFilters: [filter], + commit: .all, isVisible: true, filterLogicMode: .and ) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift index 31287e568..59002f630 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FilterState.swift @@ -56,14 +56,14 @@ extension MainContentCoordinator { filterCoordinator.applySingleFilter(filter) } - func applySelectedFilters() { - filterCoordinator.applySelectedFilters() - } - func applyAllFilters() { filterCoordinator.applyAllFilters() } + func applySoloFilter(_ filter: TableFilter) { + filterCoordinator.applySoloFilter(filter) + } + func clearAppliedFilters() { filterCoordinator.clearAppliedFilters() } @@ -80,14 +80,6 @@ extension MainContentCoordinator { filterCoordinator.closeFilterPanel() } - func selectAllFilters(_ selected: Bool) { - filterCoordinator.selectAllFilters(selected) - } - - func toggleFilterSelection(_ filter: TableFilter) { - filterCoordinator.toggleFilterSelection(filter) - } - func saveLastFiltersForActiveTable() { filterCoordinator.saveLastFiltersForActiveTable() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift index 61ed984fd..27e656954 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+URLFilter.swift @@ -13,7 +13,6 @@ extension MainContentCoordinator { columnName: TableFilter.rawSQLColumn, filterOperator: .equal, value: "", - isSelected: true, isEnabled: true, rawSQL: condition ) @@ -29,7 +28,6 @@ extension MainContentCoordinator { columnName: column, filterOperator: filterOp, value: value ?? "", - isSelected: true, isEnabled: true ) applySingleFilter(filter) diff --git a/TableProTests/Core/Coordinators/RowCountPlanTests.swift b/TableProTests/Core/Coordinators/RowCountPlanTests.swift index 0fb47756e..faf57fb98 100644 --- a/TableProTests/Core/Coordinators/RowCountPlanTests.swift +++ b/TableProTests/Core/Coordinators/RowCountPlanTests.swift @@ -12,7 +12,8 @@ import Testing struct RowCountPlanTests { private func filtered() -> TabFilterState { var state = TabFilterState() - state.appliedFilters = [TestFixtures.makeTableFilter()] + state.filters = [TestFixtures.makeTableFilter()] + state.commit = .all return state } diff --git a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index 0ef2c38d8..3338c58f9 100644 --- a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -61,7 +61,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -78,7 +77,6 @@ struct FilterSQLGeneratorTests { filterOperator: .notEqual, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -95,7 +93,6 @@ struct FilterSQLGeneratorTests { filterOperator: .contains, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -112,7 +109,6 @@ struct FilterSQLGeneratorTests { filterOperator: .notContains, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -129,7 +125,6 @@ struct FilterSQLGeneratorTests { filterOperator: .startsWith, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -146,7 +141,6 @@ struct FilterSQLGeneratorTests { filterOperator: .endsWith, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -163,7 +157,6 @@ struct FilterSQLGeneratorTests { filterOperator: .greaterThan, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -180,7 +173,6 @@ struct FilterSQLGeneratorTests { filterOperator: .greaterOrEqual, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -197,7 +189,6 @@ struct FilterSQLGeneratorTests { filterOperator: .lessThan, value: "65", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -214,7 +205,6 @@ struct FilterSQLGeneratorTests { filterOperator: .lessOrEqual, value: "65", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -231,7 +221,6 @@ struct FilterSQLGeneratorTests { filterOperator: .isNull, value: "", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -248,7 +237,6 @@ struct FilterSQLGeneratorTests { filterOperator: .isNotNull, value: "", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -265,7 +253,6 @@ struct FilterSQLGeneratorTests { filterOperator: .isEmpty, value: "", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -282,7 +269,6 @@ struct FilterSQLGeneratorTests { filterOperator: .isNotEmpty, value: "", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -299,7 +285,6 @@ struct FilterSQLGeneratorTests { filterOperator: .inList, value: "a, b, c", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -316,7 +301,6 @@ struct FilterSQLGeneratorTests { filterOperator: .notInList, value: "a, b, c", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -333,7 +317,6 @@ struct FilterSQLGeneratorTests { filterOperator: .between, value: "18", secondValue: "65", - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -350,7 +333,6 @@ struct FilterSQLGeneratorTests { filterOperator: .regex, value: "^[a-z]+@", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -360,23 +342,6 @@ struct FilterSQLGeneratorTests { // MARK: - Value Type Detection - @Test("NULL literal generates unquoted NULL") - func testNullLiteral() { - let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) - let filter = TableFilter( - id: UUID(), - columnName: "name", - filterOperator: .equal, - value: "NULL", - secondValue: nil, - isSelected: true, - isEnabled: true, - rawSQL: nil - ) - let result = generator.generateCondition(from: filter) - #expect(result == "`name` = NULL") - } - @Test("TRUE literal generates 1 for MySQL") func testTrueLiteralMySQL() { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) @@ -386,7 +351,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "TRUE", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -403,7 +367,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "FALSE", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -420,7 +383,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "42", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -437,7 +399,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "hello", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -457,7 +418,6 @@ struct FilterSQLGeneratorTests { filterOperator: .greaterThan, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ), @@ -467,7 +427,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "active", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -486,7 +445,6 @@ struct FilterSQLGeneratorTests { filterOperator: .greaterThan, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ), @@ -496,7 +454,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "active", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -523,7 +480,6 @@ struct FilterSQLGeneratorTests { filterOperator: .greaterThan, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -542,7 +498,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ), @@ -552,7 +507,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "active", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -572,7 +526,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "O'Brien", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -589,7 +542,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -606,7 +558,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: "age > 18" ) @@ -668,7 +619,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -685,7 +635,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -702,7 +651,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -719,7 +667,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -738,7 +685,6 @@ struct FilterSQLGeneratorTests { filterOperator: .contains, value: "50%", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -755,7 +701,6 @@ struct FilterSQLGeneratorTests { filterOperator: .contains, value: "test_value", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -772,7 +717,6 @@ struct FilterSQLGeneratorTests { filterOperator: .startsWith, value: "test_%", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -791,7 +735,6 @@ struct FilterSQLGeneratorTests { filterOperator: .regex, value: "^[a-z]+@", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -808,7 +751,6 @@ struct FilterSQLGeneratorTests { filterOperator: .regex, value: "^[a-z]+@", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -825,7 +767,6 @@ struct FilterSQLGeneratorTests { filterOperator: .regex, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -845,7 +786,6 @@ struct FilterSQLGeneratorTests { filterOperator: .greaterThan, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -879,7 +819,6 @@ struct FilterSQLGeneratorTests { filterOperator: .between, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -896,7 +835,6 @@ struct FilterSQLGeneratorTests { filterOperator: .inList, value: "", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -913,7 +851,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "TRUE", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -930,7 +867,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "FALSE", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -949,7 +885,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "test", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -966,7 +901,6 @@ struct FilterSQLGeneratorTests { filterOperator: .regex, value: "^[a-z]+@", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -983,7 +917,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "TRUE", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -1000,7 +933,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "FALSE", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -1017,7 +949,6 @@ struct FilterSQLGeneratorTests { filterOperator: .contains, value: "50%", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -1035,7 +966,6 @@ struct FilterSQLGeneratorTests { filterOperator: .contains, value: "a!b", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -1053,7 +983,6 @@ struct FilterSQLGeneratorTests { filterOperator: .greaterThan, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ), @@ -1063,7 +992,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "active", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -1082,7 +1010,6 @@ struct FilterSQLGeneratorTests { filterOperator: .greaterThan, value: "18", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ), @@ -1092,7 +1019,6 @@ struct FilterSQLGeneratorTests { filterOperator: .equal, value: "active", secondValue: nil, - isSelected: true, isEnabled: true, rawSQL: nil ) @@ -1108,7 +1034,7 @@ struct FilterSQLGeneratorTests { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", filterOperator: .equal, - value: "NULL", secondValue: nil, isSelected: true, isEnabled: true, rawSQL: nil + value: "NULL", secondValue: nil, isEnabled: true, rawSQL: nil ) let result = generator.generateCondition(from: filter) #expect(result == "`name` IS NULL") @@ -1119,7 +1045,7 @@ struct FilterSQLGeneratorTests { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", filterOperator: .equal, - value: "null", secondValue: nil, isSelected: true, isEnabled: true, rawSQL: nil + value: "null", secondValue: nil, isEnabled: true, rawSQL: nil ) let result = generator.generateCondition(from: filter) #expect(result == "`name` IS NULL") @@ -1130,7 +1056,7 @@ struct FilterSQLGeneratorTests { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", filterOperator: .notEqual, - value: "NULL", secondValue: nil, isSelected: true, isEnabled: true, rawSQL: nil + value: "NULL", secondValue: nil, isEnabled: true, rawSQL: nil ) let result = generator.generateCondition(from: filter) #expect(result == "`name` IS NOT NULL") @@ -1141,7 +1067,7 @@ struct FilterSQLGeneratorTests { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "name", filterOperator: .equal, - value: "hello", secondValue: nil, isSelected: true, isEnabled: true, rawSQL: nil + value: "hello", secondValue: nil, isEnabled: true, rawSQL: nil ) let result = generator.generateCondition(from: filter) #expect(result == "`name` = 'hello'") @@ -1154,7 +1080,7 @@ struct FilterSQLGeneratorTests { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "status", filterOperator: .inList, - value: "1, NULL, 3", secondValue: nil, isSelected: true, isEnabled: true, rawSQL: nil + value: "1, NULL, 3", secondValue: nil, isEnabled: true, rawSQL: nil ) let result = generator.generateCondition(from: filter) #expect(result == "(`status` IN (1, 3) OR `status` IS NULL)") @@ -1165,7 +1091,7 @@ struct FilterSQLGeneratorTests { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "status", filterOperator: .notInList, - value: "1, NULL, 3", secondValue: nil, isSelected: true, isEnabled: true, rawSQL: nil + value: "1, NULL, 3", secondValue: nil, isEnabled: true, rawSQL: nil ) let result = generator.generateCondition(from: filter) #expect(result == "(`status` NOT IN (1, 3) AND `status` IS NOT NULL)") @@ -1176,7 +1102,7 @@ struct FilterSQLGeneratorTests { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "id", filterOperator: .inList, - value: "1, 2, 3", secondValue: nil, isSelected: true, isEnabled: true, rawSQL: nil + value: "1, 2, 3", secondValue: nil, isEnabled: true, rawSQL: nil ) let result = generator.generateCondition(from: filter) #expect(result == "`id` IN (1, 2, 3)") @@ -1187,7 +1113,7 @@ struct FilterSQLGeneratorTests { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( id: UUID(), columnName: "status", filterOperator: .inList, - value: "NULL", secondValue: nil, isSelected: true, isEnabled: true, rawSQL: nil + value: "NULL", secondValue: nil, isEnabled: true, rawSQL: nil ) let result = generator.generateCondition(from: filter) #expect(result == "`status` IS NULL") diff --git a/TableProTests/Helpers/TestFixtures.swift b/TableProTests/Helpers/TestFixtures.swift index e4801b9f3..dae20f17e 100644 --- a/TableProTests/Helpers/TestFixtures.swift +++ b/TableProTests/Helpers/TestFixtures.swift @@ -36,6 +36,7 @@ enum TestFixtures { op: FilterOperator = .equal, value: String = "1", secondValue: String? = nil, + isEnabled: Bool = true, rawSQL: String? = nil ) -> TableFilter { return TableFilter( @@ -44,8 +45,7 @@ enum TestFixtures { filterOperator: op, value: value, secondValue: secondValue, - isSelected: true, - isEnabled: true, + isEnabled: isEnabled, rawSQL: rawSQL ) } diff --git a/TableProTests/Models/TabFilterStateTests.swift b/TableProTests/Models/TabFilterStateTests.swift new file mode 100644 index 000000000..49b94ab65 --- /dev/null +++ b/TableProTests/Models/TabFilterStateTests.swift @@ -0,0 +1,77 @@ +// +// TabFilterStateTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("TabFilterState") +struct TabFilterStateTests { + @Test("appliedFilters is empty when nothing is committed") + func noCommitYieldsEmpty() { + var state = TabFilterState() + state.filters = [TestFixtures.makeTableFilter(column: "id")] + #expect(state.commit == nil) + #expect(state.appliedFilters.isEmpty) + #expect(!state.hasAppliedFilters) + } + + @Test("commit .all excludes disabled and invalid filters") + func commitAllExcludesDisabledAndInvalid() { + let active = TestFixtures.makeTableFilter(column: "id", value: "1") + let disabled = TestFixtures.makeTableFilter(column: "name", value: "a", isEnabled: false) + let invalid = TestFixtures.makeTableFilter(column: "", value: "") + var state = TabFilterState() + state.filters = [active, disabled, invalid] + state.commit = .all + #expect(state.appliedFilters == [active]) + } + + @Test("commit .solo returns only that filter, forced active even if it was disabled") + func commitSoloForcesActive() { + let other = TestFixtures.makeTableFilter(column: "id", value: "1") + let target = TestFixtures.makeTableFilter(column: "name", value: "a", isEnabled: false) + var state = TabFilterState() + state.filters = [other, target] + state.commit = .solo(target.id) + #expect(state.appliedFilters.map(\.id) == [target.id]) + #expect(state.appliedFilters.first?.isEnabled == true) + } + + @Test("commit .solo on a missing id yields empty") + func commitSoloMissingIdYieldsEmpty() { + var state = TabFilterState() + state.filters = [TestFixtures.makeTableFilter(column: "id")] + state.commit = .solo(UUID()) + #expect(state.appliedFilters.isEmpty) + } + + @Test("appliedFilters tracks filters automatically with no separate write") + func appliedFiltersDerivesFromFilters() { + let first = TestFixtures.makeTableFilter(column: "id", value: "1") + let second = TestFixtures.makeTableFilter(column: "name", value: "a") + var state = TabFilterState() + state.filters = [first, second] + state.commit = .all + #expect(state.appliedFilters.count == 2) + + state.filters.removeAll { $0.id == second.id } + #expect(state.appliedFilters == [first]) + } + + @Test("TabFilterState round-trips through Codable including the solo commit") + func codableRoundTrip() throws { + let filter = TestFixtures.makeTableFilter(column: "id", value: "1") + var state = TabFilterState(isVisible: true) + state.filters = [filter] + state.commit = .solo(filter.id) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(TabFilterState.self, from: data) + + #expect(decoded == state) + #expect(decoded.appliedFilters.map(\.id) == [filter.id]) + } +} diff --git a/TableProTests/Views/Main/FilterRestoreTests.swift b/TableProTests/Views/Main/FilterRestoreTests.swift index f11c14c8c..48a7b1780 100644 --- a/TableProTests/Views/Main/FilterRestoreTests.swift +++ b/TableProTests/Views/Main/FilterRestoreTests.swift @@ -25,6 +25,22 @@ struct FilterRestoreTests { #expect(result.isVisible) } + @Test("Restore keeps disabled filters in the panel but out of the applied set") + func restoreKeepsDisabledFilterInactive() { + let active = TestFixtures.makeTableFilter(column: "email", value: "a@b.com") + let inactive = TestFixtures.makeTableFilter(column: "name", value: "bob", isEnabled: false) + + let result = FilterCoordinator.resolvedRestoredState( + panelState: .restoreLast, + saved: [active, inactive], + current: TabFilterState() + ) + + #expect(result.filters == [active, inactive]) + #expect(result.appliedFilters == [active]) + #expect(result.isVisible) + } + @Test("Restore Last with no saved filters keeps the bar hidden") func restoreLastWithNoFiltersKeepsBarHidden() { let result = FilterCoordinator.resolvedRestoredState( diff --git a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift index f725a7b19..989200557 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift @@ -82,7 +82,7 @@ struct MainContentCoordinatorTabSwitchTests { } var state = TabFilterState() state.filters = [TestFixtures.makeTableFilter(column: "id", op: .equal, value: "42")] - state.appliedFilters = state.filters + state.commit = .all state.isVisible = true tabManager.tabs[oldIndex].filterState = state @@ -169,7 +169,7 @@ struct MainContentCoordinatorTabSwitchTests { } var savedFilter = TabFilterState() savedFilter.filters = [TestFixtures.makeTableFilter(column: "name", op: .equal, value: "Bob")] - savedFilter.appliedFilters = savedFilter.filters + savedFilter.commit = .all savedFilter.isVisible = true tabManager.tabs[newIndex].filterState = savedFilter @@ -444,7 +444,7 @@ struct MainContentCoordinatorTabSwitchTests { tabManager.tabs[index].filterState.filterLogicMode = .or tabManager.tabs[index].filterState.isVisible = true - coordinator.applySelectedFilters() + coordinator.applyAllFilters() #expect(coordinator.selectedTabFilterState.appliedFilters.count == 2) #expect(coordinator.selectedTabFilterState.filterLogicMode == .or) @@ -454,6 +454,49 @@ struct MainContentCoordinatorTabSwitchTests { #expect(coordinator.selectedTabFilterState.isVisible == true) } + @Test("Apply All runs only enabled filters but keeps disabled ones in the panel") + func applyAllExcludesDisabledFilters() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager, tableName: "users") + seedRows(coordinator, for: tabId) + + let active = TestFixtures.makeTableFilter(column: "id", op: .equal, value: "1") + let inactive = TestFixtures.makeTableFilter(column: "name", op: .contains, value: "a", isEnabled: false) + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("Expected tab to exist") + return + } + tabManager.tabs[index].filterState.filters = [active, inactive] + + coordinator.applyAllFilters() + + #expect(coordinator.selectedTabFilterState.filters.count == 2) + #expect(coordinator.selectedTabFilterState.appliedFilters == [active]) + } + + @Test("Apply on a single row queries by only that row without changing checkbox state") + func applySoloFilterRunsOnlyThatRowAndKeepsState() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager, tableName: "users") + seedRows(coordinator, for: tabId) + + let first = TestFixtures.makeTableFilter(column: "id", op: .equal, value: "1") + let second = TestFixtures.makeTableFilter(column: "name", op: .contains, value: "a", isEnabled: false) + let third = TestFixtures.makeTableFilter(column: "email", op: .contains, value: "b") + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("Expected tab to exist") + return + } + tabManager.tabs[index].filterState.filters = [first, second, third] + + coordinator.applySoloFilter(second) + + let state = coordinator.selectedTabFilterState + #expect(state.filters.map(\.isEnabled) == [true, false, true]) + #expect(state.appliedFilters.map(\.id) == [second.id]) + #expect(state.appliedFilters.first?.isEnabled == true) + } + @Test("Applying filters persists them immediately so a reopened table restores them") func applyFiltersPersistForReopen() { let (coordinator, tabManager) = makeCoordinator() @@ -488,6 +531,41 @@ struct MainContentCoordinatorTabSwitchTests { #expect(persisted.first?.columnName == "id") } + @Test("Disabled filters persist so they are available again after reopening a table") + func disabledFiltersPersistForReopen() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager, tableName: "users") + seedRows(coordinator, for: tabId) + defer { + FilterSettingsStorage.shared.clearLastFilters( + for: "users", + connectionId: coordinator.connectionId, + databaseName: "", + schemaName: nil + ) + } + + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("Expected tab to exist") + return + } + tabManager.tabs[index].filterState.filters = [ + TestFixtures.makeTableFilter(column: "id", op: .equal, value: "1"), + TestFixtures.makeTableFilter(column: "name", op: .contains, value: "a", isEnabled: false) + ] + + coordinator.applyAllFilters() + + let persisted = FilterSettingsStorage.shared.loadLastFilters( + for: "users", + connectionId: coordinator.connectionId, + databaseName: "", + schemaName: nil + ) + #expect(persisted.count == 2) + #expect(persisted.contains { $0.columnName == "name" && !$0.isEnabled }) + } + @Test("DataChangeManager restoreState rehydrates table context and changes") func dataChangeManagerRestoresFromSnapshot() { let manager = DataChangeManager() diff --git a/docs/features/filtering.mdx b/docs/features/filtering.mdx index 7679d39a8..d0e600dab 100644 --- a/docs/features/filtering.mdx +++ b/docs/features/filtering.mdx @@ -7,9 +7,13 @@ description: Filter table data with 18 operators, raw SQL, and saved presets Press `Cmd+F` while viewing a table to open the filter panel. Type a raw SQL WHERE clause and press `Enter` to filter. Raw SQL is the default mode. -Each row has a column picker, operator, value field, and **+**/**−** buttons. Multiple rows combine with **AND** or **OR** (toggle in the header). Click **Apply** or press `Enter` to activate. Click **Unset** to clear all. +Each row has a checkbox, a column picker, operator, value field, and **+**/**−** buttons. Multiple rows combine with **AND** or **OR** (toggle in the header). Click **Apply**, press `Enter` in a value field, or press `Cmd+Return` to run all active filters. Click **Unset** to clear all. -When you reopen a table, TablePro restores the filter you last applied to it, including after you quit and relaunch. If you clear the filter with **Unset** or remove its conditions with **−**, reopening the table shows it unfiltered. Filters are remembered per connection. Tables with active filters open in a new tab when you click another table. +Uncheck a row to turn that filter off without deleting it. The header **Apply** runs only the checked filters; unchecked ones stay dimmed in the panel so you can switch them back on later. Toggling a checkbox does not re-run the query on its own, press **Apply** when you are ready. + +Each row also has its own **Apply** button. It filters the table by just that row, without changing any checkbox. The checkboxes stay as they are, so you can test one condition on its own and then go back to the checked set with the header **Apply**. + +When you reopen a table, TablePro restores the filters you last applied to it, including the ones you left unchecked, after you quit and relaunch. If you clear the filter with **Unset** or remove its conditions with **−**, reopening the table shows it unfiltered. Filters are remembered per connection. Tables with active filters open in a new tab when you click another table. ## Operators From c53dd634a323809b3e6dacc90401c6f4cbf6e707 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 3 Jun 2026 02:17:25 +0700 Subject: [PATCH 2/2] refactor(datagrid): address review (solo-button a11y state, drop what-comments, cover solo edge cases) --- TablePro/Models/Database/TableFilter.swift | 8 ++-- TablePro/Views/Filter/FilterRowView.swift | 1 + ...MainContentCoordinatorTabSwitchTests.swift | 38 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/TablePro/Models/Database/TableFilter.swift b/TablePro/Models/Database/TableFilter.swift index bcb391f57..da5ba8f39 100644 --- a/TablePro/Models/Database/TableFilter.swift +++ b/TablePro/Models/Database/TableFilter.swift @@ -94,12 +94,12 @@ enum FilterOperator: String, CaseIterable, Identifiable, Codable { /// Represents a single table filter condition struct TableFilter: Identifiable, Equatable, Hashable, Codable { let id: UUID - var columnName: String // Column to filter on, or "__RAW__" for raw SQL + var columnName: String var filterOperator: FilterOperator var value: String - var secondValue: String? // For BETWEEN operator - var isEnabled: Bool // Whether filter is active - var rawSQL: String? // For raw SQL mode + var secondValue: String? + var isEnabled: Bool + var rawSQL: String? /// Special column name for raw SQL mode static let rawSQLColumn = "__RAW__" diff --git a/TablePro/Views/Filter/FilterRowView.swift b/TablePro/Views/Filter/FilterRowView.swift index 4a46ddbf3..c9ebcc9a9 100644 --- a/TablePro/Views/Filter/FilterRowView.swift +++ b/TablePro/Views/Filter/FilterRowView.swift @@ -177,6 +177,7 @@ struct FilterRowView: View { .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")) diff --git a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift index 989200557..215774a14 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift @@ -474,6 +474,44 @@ struct MainContentCoordinatorTabSwitchTests { #expect(coordinator.selectedTabFilterState.appliedFilters == [active]) } + @Test("Removing the soloed filter clears the applied set") + func removingSoloedFilterClearsCommit() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager, tableName: "users") + seedRows(coordinator, for: tabId) + + let first = TestFixtures.makeTableFilter(column: "id", op: .equal, value: "1") + let second = TestFixtures.makeTableFilter(column: "name", op: .contains, value: "a") + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("Expected tab to exist") + return + } + tabManager.tabs[index].filterState.filters = [first, second] + + coordinator.applySoloFilter(second) + #expect(coordinator.selectedTabFilterState.appliedFilters.map(\.id) == [second.id]) + + coordinator.removeFilter(second) + #expect(coordinator.selectedTabFilterState.appliedFilters.isEmpty) + } + + @Test("Soloing an invalid filter does nothing") + func soloingInvalidFilterIsNoOp() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager, tableName: "users") + seedRows(coordinator, for: tabId) + + let invalid = TestFixtures.makeTableFilter(column: "", value: "") + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("Expected tab to exist") + return + } + tabManager.tabs[index].filterState.filters = [invalid] + + coordinator.applySoloFilter(invalid) + #expect(coordinator.selectedTabFilterState.appliedFilters.isEmpty) + } + @Test("Apply on a single row queries by only that row without changing checkbox state") func applySoloFilterRunsOnlyThatRowAndKeepsState() { let (coordinator, tabManager) = makeCoordinator()