git.iko.soy/parseable-macos
on master help sign in
Save column selection and order with saved queries (#20)
bd84bf5iko
ParseableViewer/App/AppState.swift+1−1
@@ -35,7 +35,7 @@ final class AppState {
35 // MARK: - Saved Queries
35 // MARK: - Saved Queries
36 var savedQueries: [SavedQuery] = []
36 var savedQueries: [SavedQuery] = []
37 /// Set by sidebar when user clicks a saved query; consumed by QueryView.
37 /// Set by sidebar when user clicks a saved query; consumed by QueryView.
38− var pendingSavedQuerySQL: String?
38+ var pendingSavedQuery: SavedQuery?
39
39
40 enum AppTab: String, CaseIterable, Identifiable {
40 enum AppTab: String, CaseIterable, Identifiable {
41 case query = "Query"
41 case query = "Query"
ParseableViewer/Services/ConnectionStore.swift+7−1
@@ -63,12 +63,18 @@ struct SavedQuery: Identifiable, Codable, Hashable, Sendable {
63 var sql: String
63 var sql: String
64 var stream: String
64 var stream: String
65 var createdAt: Date
65 var createdAt: Date
66+ var columnOrder: [String]?
67+ var hiddenColumns: Set<String>?
66
68
67− init(id: UUID = UUID(), name: String, sql: String, stream: String, createdAt: Date = Date()) {
69+ init(id: UUID = UUID(), name: String, sql: String, stream: String,
70+ columnOrder: [String]? = nil, hiddenColumns: Set<String>? = nil,
71+ createdAt: Date = Date()) {
68 self.id = id
72 self.id = id
69 self.name = name
73 self.name = name
70 self.sql = sql
74 self.sql = sql
71 self.stream = stream
75 self.stream = stream
76+ self.columnOrder = columnOrder
77+ self.hiddenColumns = hiddenColumns
72 self.createdAt = createdAt
78 self.createdAt = createdAt
73 }
79 }
74}
80}
ParseableViewer/ViewModels/QueryViewModel.swift+15−1
@@ -11,6 +11,8 @@ final class QueryViewModel {
11 var columnOrder: [String] = []
11 var columnOrder: [String] = []
12 var hiddenColumns: Set<String> = []
12 var hiddenColumns: Set<String> = []
13 var autoHiddenColumns: Set<String> = []
13 var autoHiddenColumns: Set<String> = []
14+ /// Column config from a saved query, applied on next query execution.
15+ var pendingColumnConfig: ColumnConfiguration?
14 private var currentStream: String?
16 private var currentStream: String?
15 var isLoading = false
17 var isLoading = false
16 var errorMessage: String?
18 var errorMessage: String?
@@ -191,7 +193,19 @@ final class QueryViewModel {
191 columns = extractedColumns
193 columns = extractedColumns
192 currentStream = stream
194 currentStream = stream
193
195
196+ // Use pending config (from a saved query) if available,
197+ // otherwise fall back to the per-stream default.
198+ let config: ColumnConfiguration?
199+ if let pending = pendingColumnConfig {
200+ config = pending
201+ pendingColumnConfig = nil
202+ } else if let stream {
194− guard let stream, let config = Self.loadColumnConfig(for: stream) else {
203+ config = Self.loadColumnConfig(for: stream)
204+ } else {
205+ config = nil
206+ }
207+
208+ guard let config else {
195 columnOrder = extractedColumns
209 columnOrder = extractedColumns
196 hiddenColumns = []
210 hiddenColumns = []
197 return
211 return
ParseableViewer/Views/QueryView.swift+15−5
@@ -270,10 +270,16 @@ struct QueryView: View {
270 viewModel.cancelQuery()
270 viewModel.cancelQuery()
271 }
271 }
272 }
272 }
273− .onChange(of: appState.pendingSavedQuerySQL) { _, newSQL in
273+ .onChange(of: appState.pendingSavedQuery) { _, newQuery in
274− if let sql = newSQL {
274+ if let query = newQuery {
275− viewModel.sqlQuery = sql
275+ viewModel.sqlQuery = query.sql
276− appState.pendingSavedQuerySQL = nil
276+ if let order = query.columnOrder {
277+ viewModel.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
278+ order: order,
279+ hidden: query.hiddenColumns ?? []
280+ )
281+ }
282+ appState.pendingSavedQuery = nil
277 }
283 }
278 }
284 }
279 .alert("Export Error", isPresented: $showExportError) {
285 .alert("Export Error", isPresented: $showExportError) {
@@ -291,10 +297,14 @@ struct QueryView: View {
291 Button("Cancel") { showSaveQuerySheet = false }
297 Button("Cancel") { showSaveQuerySheet = false }
292 Spacer()
298 Spacer()
293 Button("Save") {
299 Button("Save") {
300+ let colOrder = viewModel.columnOrder.isEmpty ? nil : viewModel.columnOrder
301+ let hidden = viewModel.hiddenColumns.isEmpty ? nil : viewModel.hiddenColumns
294 let query = SavedQuery(
302 let query = SavedQuery(
295 name: saveQueryName,
303 name: saveQueryName,
296 sql: viewModel.sqlQuery,
304 sql: viewModel.sqlQuery,
297− stream: appState.selectedStream ?? ""
305+ stream: appState.selectedStream ?? "",
306+ columnOrder: colOrder,
307+ hiddenColumns: hidden
298 )
308 )
299 appState.addSavedQuery(query)
309 appState.addSavedQuery(query)
300 saveQueryName = ""
310 saveQueryName = ""
ParseableViewer/Views/SidebarView.swift+1−1
@@ -285,7 +285,7 @@ struct SavedQueryRow: View {
285 var body: some View {
285 var body: some View {
286 Button {
286 Button {
287 appState.selectedStream = query.stream
287 appState.selectedStream = query.stream
288− appState.pendingSavedQuerySQL = query.sql
288+ appState.pendingSavedQuery = query
289 appState.currentTab = .query
289 appState.currentTab = .query
290 } label: {
290 } label: {
291 HStack {
291 HStack {
ParseableViewerTests/ConnectionStoreTests.swift+28
@@ -105,6 +105,34 @@ final class ConnectionStoreTests: XCTestCase {
105 XCTAssertEqual(loaded[0].stream, "backend-logs")
105 XCTAssertEqual(loaded[0].stream, "backend-logs")
106 }
106 }
107
107
108+ func testSavedQueryStoreRoundTripWithColumnConfig() {
109+ let query = SavedQuery(
110+ name: "With Columns",
111+ sql: "SELECT * FROM logs",
112+ stream: "backend-logs",
113+ columnOrder: ["level", "message", "p_timestamp"],
114+ hiddenColumns: ["p_metadata"]
115+ )
116+ SavedQueryStore.save([query])
117+ let loaded = SavedQueryStore.load()
118+ XCTAssertEqual(loaded.count, 1)
119+ XCTAssertEqual(loaded[0].columnOrder, ["level", "message", "p_timestamp"])
120+ XCTAssertEqual(loaded[0].hiddenColumns, ["p_metadata"])
121+ }
122+
123+ func testSavedQueryStoreBackwardCompatibility() {
124+ // Simulate a saved query without column fields (pre-feature data)
125+ let json = """
126+ [{"id":"00000000-0000-0000-0000-000000000001","name":"Old Query","sql":"SELECT 1","stream":"s","createdAt":0}]
127+ """
128+ UserDefaults.standard.set(json.data(using: .utf8), forKey: "parseable_saved_queries")
129+ let loaded = SavedQueryStore.load()
130+ XCTAssertEqual(loaded.count, 1)
131+ XCTAssertEqual(loaded[0].name, "Old Query")
132+ XCTAssertNil(loaded[0].columnOrder)
133+ XCTAssertNil(loaded[0].hiddenColumns)
134+ }
135+
108 func testSavedQueryStoreMultiple() {
136 func testSavedQueryStoreMultiple() {
109 let q1 = SavedQuery(name: "Query 1", sql: "SELECT 1", stream: "s1")
137 let q1 = SavedQuery(name: "Query 1", sql: "SELECT 1", stream: "s1")
110 let q2 = SavedQuery(name: "Query 2", sql: "SELECT 2", stream: "s2")
138 let q2 = SavedQuery(name: "Query 2", sql: "SELECT 2", stream: "s2")
ParseableViewerTests/QueryViewModelTests.swift+58
@@ -544,6 +544,64 @@ final class QueryViewModelTests: XCTestCase {
544 XCTAssertTrue(vm.autoHiddenColumns.isEmpty)
544 XCTAssertTrue(vm.autoHiddenColumns.isEmpty)
545 }
545 }
546
546
547+ // MARK: - Pending column config from saved queries
548+
549+ func testApplyColumnConfigUsesPendingConfig() {
550+ let vm = QueryViewModel()
551+ vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
552+ order: ["c", "a", "b"],
553+ hidden: ["b"]
554+ )
555+ vm.applyColumnConfig(extractedColumns: ["a", "b", "c"], stream: "test_stream")
556+ XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
557+ XCTAssertEqual(vm.hiddenColumns, ["b"])
558+ XCTAssertNil(vm.pendingColumnConfig, "Pending config should be consumed")
559+ }
560+
561+ func testApplyColumnConfigPendingOverridesStreamDefault() {
562+ let vm = QueryViewModel()
563+ // Save a stream-level config
564+ let streamConfig = QueryViewModel.ColumnConfiguration(order: ["a", "b", "c"], hidden: [])
565+ if let data = try? JSONEncoder().encode(streamConfig) {
566+ UserDefaults.standard.set(data, forKey: "parseable_column_config_pending_test_stream")
567+ }
568+
569+ // Set a different pending config (from saved query)
570+ vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
571+ order: ["c", "b", "a"],
572+ hidden: ["a"]
573+ )
574+ vm.applyColumnConfig(extractedColumns: ["a", "b", "c"], stream: "pending_test_stream")
575+ XCTAssertEqual(vm.columnOrder, ["c", "b", "a"])
576+ XCTAssertEqual(vm.hiddenColumns, ["a"])
577+
578+ // Clean up
579+ UserDefaults.standard.removeObject(forKey: "parseable_column_config_pending_test_stream")
580+ }
581+
582+ func testApplyColumnConfigPendingMergesNewColumns() {
583+ let vm = QueryViewModel()
584+ // Pending config references columns a and b, but results also have c
585+ vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
586+ order: ["b", "a"],
587+ hidden: []
588+ )
589+ vm.applyColumnConfig(extractedColumns: ["a", "b", "c"], stream: "test_stream")
590+ XCTAssertEqual(vm.columnOrder, ["b", "a", "c"])
591+ }
592+
593+ func testApplyColumnConfigPendingRemovesStaleColumns() {
594+ let vm = QueryViewModel()
595+ // Pending config references "old" which no longer exists in results
596+ vm.pendingColumnConfig = QueryViewModel.ColumnConfiguration(
597+ order: ["old", "a", "b"],
598+ hidden: ["old"]
599+ )
600+ vm.applyColumnConfig(extractedColumns: ["a", "b"], stream: "test_stream")
601+ XCTAssertEqual(vm.columnOrder, ["a", "b"])
602+ XCTAssertTrue(vm.hiddenColumns.isEmpty)
603+ }
604+
547 func testCSVExportExcludesAutoHiddenColumns() {
605 func testCSVExportExcludesAutoHiddenColumns() {
548 let vm = QueryViewModel()
606 let vm = QueryViewModel()
549 vm.results = [
607 vm.results = [