gitbay/Views/Discovery/SavedQueriesView.swift

v1.13.0
gitbay-ios/gitbay/Views/Discovery/SavedQueriesView.swift history · blame · raw

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}