gitbay/Views/Discovery/SavedQueriesView.swift
255 lines · 9911 bytes
1import SwiftUI
2
3/// Your saved queries: open one for its results, swipe to pin it to the
4/// dashboard or remove it, and save a new one from the + button.
5struct SavedQueriesView: View {
6
7 @State private var model: SavedQueriesViewModel
8 @State private var composing = false
9 /// The query the sheet edits; nil saves a new one.
10 @State private var editing: SavedQuery?
11 @State private var removing: SavedQuery?
12
13 init(client: GitbayClient) {
14 _model = State(initialValue: SavedQueriesViewModel(client: client))
15 }
16
17 var body: some View {
18 List {
19 if let error = model.actionError, !composing {
20 Section { GBNotice(error, .gbWarn) }
21 }
22 ForEach(model.state.value ?? []) { query in
23 NavigationLink(value: RepoRoute.queryResults(name: query.name)) {
24 VStack(alignment: .leading, spacing: 3) {
25 HStack(spacing: 6) {
26 Text(query.name)
27 .font(.gbSans(.subheadline).weight(.semibold))
28 if query.pinned {
29 GBChip("pinned", .secondary)
30 }
31 }
32 Text(query.query)
33 .font(.gbMono(.caption))
34 .foregroundStyle(.secondary)
35 .lineLimit(2)
36 }
37 .accessibilityElement(children: .combine)
38 }
39 .accessibilityIdentifier("saved-query-\(query.name)")
40 .swipeActions(edge: .trailing) {
41 Button(role: .destructive) {
42 removing = query
43 } label: {
44 Label("Remove", systemImage: "trash")
45 }
46 Button {
47 Task { await model.setPinned(query, !query.pinned) }
48 } label: {
49 Label(query.pinned ? "Unpin" : "Pin", systemImage: query.pinned ? "pin.slash" : "pin")
50 }
51 .tint(Color.gbAccent)
52 }
53 .contextMenu {
54 Button {
55 editing = query
56 composing = true
57 } label: {
58 Label("Edit", systemImage: "pencil")
59 }
60 Button {
61 Task { await model.setPinned(query, !query.pinned) }
62 } label: {
63 Label(query.pinned ? "Unpin from Dashboard" : "Pin to Dashboard",
64 systemImage: query.pinned ? "pin.slash" : "pin")
65 }
66 Button(role: .destructive) {
67 removing = query
68 } label: {
69 Label("Remove", systemImage: "trash")
70 }
71 }
72 }
73 }
74 .overlay { LoadStateOverlay(state: model.state) }
75 .navigationTitle("Saved Queries")
76 .navigationBarTitleDisplayMode(.inline)
77 .toolbar {
78 ToolbarItem(placement: .topBarTrailing) {
79 Button {
80 editing = nil
81 composing = true
82 } label: {
83 Image(systemName: "plus")
84 }
85 .accessibilityLabel("Save a Query")
86 .accessibilityIdentifier("saved-query-create")
87 }
88 }
89 .confirmationDialog(
90 removing.map { "Remove \($0.name)?" } ?? "",
91 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
92 titleVisibility: .visible,
93 presenting: removing
94 ) { query in
95 Button("Remove", role: .destructive) { Task { await model.remove(query) } }
96 Button("Cancel", role: .cancel) {}
97 }
98 .sheet(isPresented: $composing, onDismiss: { model.clearError() }) {
99 SaveQuerySheet(model: model, editing: editing) { composing = false }
100 }
101 .task { await model.load() }
102 .refreshable { await model.load() }
103 }
104}
105
106private struct SaveQuerySheet: View {
107
108 let model: SavedQueriesViewModel
109 let onSaved: () -> Void
110
111 @State private var name: String
112 @State private var query: String
113 @State private var replace: Bool
114 @State private var sent = false
115 @Environment(\.dismiss) private var dismiss
116
117 /// `editing` fills the sheet with a saved query to change, replacing it
118 /// under the same name: how one whose text no longer parses is fixed.
119 init(model: SavedQueriesViewModel, editing: SavedQuery?, onSaved: @escaping () -> Void) {
120 self.model = model
121 self.onSaved = onSaved
122 _name = State(initialValue: editing?.name ?? "")
123 _query = State(initialValue: editing?.query ?? "")
124 _replace = State(initialValue: editing != nil)
125 }
126
127 var body: some View {
128 NavigationStack {
129 Form {
130 Section("Name") {
131 TextField("mine", text: $name)
132 .font(.gbMono(.body))
133 .autocorrectionDisabled()
134 .textInputAutocapitalization(.never)
135 .accessibilityIdentifier("saved-query-name")
136 }
137 Section {
138 TextField("is:open author:@me", text: $query, axis: .vertical)
139 .font(.gbMono(.body))
140 .lineLimit(1...4)
141 .autocorrectionDisabled()
142 .textInputAutocapitalization(.never)
143 .accessibilityIdentifier("saved-query-text")
144 Toggle("Replace one with this name", isOn: $replace)
145 .font(.gbSans(.subheadline))
146 } header: {
147 Text("Query")
148 } footer: {
149 Text("Qualifiers: repo:, owner:, is:open, is:closed, is:merged, is:issue, is:mr, label:, no:label, milestone:, assignee: and author:, with @me for you. Other words search titles and bodies.")
150 }
151 if sent, let error = model.actionError {
152 Section { GBNotice(error) }
153 }
154 }
155 .navigationTitle("Save a Query")
156 .navigationBarTitleDisplayMode(.inline)
157 .toolbar {
158 ToolbarItem(placement: .cancellationAction) {
159 Button("Cancel") { dismiss() }
160 }
161 ToolbarItem(placement: .confirmationAction) {
162 if model.working {
163 ProgressView()
164 } else {
165 Button("Save") {
166 sent = true
167 Task {
168 if await model.save(name: name, query: query, replace: replace) { onSaved() }
169 }
170 }
171 .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty
172 || query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
173 .accessibilityIdentifier("saved-query-save")
174 }
175 }
176 }
177 .interactiveDismissDisabled(model.working)
178 }
179 }
180}
181
182/// What a saved query matches, newest first, as `query run` pages it.
183struct QueryResultsView: View {
184
185 @State private var model: QueryResultsViewModel
186
187 init(client: GitbayClient, name: String) {
188 _model = State(initialValue: QueryResultsViewModel(client: client, name: name))
189 }
190
191 var body: some View {
192 List {
193 ForEach(model.list.state.value ?? []) { item in
194 NavigationLink(value: item.route) {
195 QueryItemRow(item: item, showsState: true)
196 }
197 .onAppear {
198 if item.id == model.list.state.value?.last?.id { Task { await model.list.loadMore() } }
199 }
200 }
201 PageFooter(list: model.list)
202 }
203 .overlay { LoadStateOverlay(state: model.list.state) }
204 .navigationTitle(model.name)
205 .navigationBarTitleDisplayMode(.inline)
206 .task { if model.list.needsLoad { await model.load() } }
207 .refreshable { await model.load() }
208 }
209}
210
211/// One issue or merge request a query matched.
212struct QueryItemRow: View {
213
214 let item: QueryItem
215 var showsState = false
216
217 var body: some View {
218 VStack(alignment: .leading, spacing: 3) {
219 HStack(alignment: .firstTextBaseline, spacing: 6) {
220 Text(item.title)
221 .font(.gbSans(.subheadline).weight(.semibold))
222 .lineLimit(2)
223 if showsState {
224 Spacer(minLength: 4)
225 GBChip(item.draft == true ? "draft" : item.state, stateColor)
226 }
227 }
228 HStack(spacing: 4) {
229 Text(item.ref)
230 Text("·")
231 Text(item.author)
232 .foregroundStyle(Color.gbAccent)
233 if let milestone = item.milestone {
234 Text("·")
235 Text("in \(milestone)")
236 }
237 Text("·")
238 Text(item.updatedAt, format: .relative(presentation: .named))
239 }
240 .font(.gbSans(.caption))
241 .foregroundStyle(.secondary)
242 .lineLimit(1)
243 }
244 .padding(.vertical, 2)
245 }
246
247 private var stateColor: Color {
248 if item.draft == true { return .secondary }
249 switch item.state {
250 case "open": return .gbOK
251 case "merged": return .gbDone
252 default: return .gbBad
253 }
254 }
255}