Commit 1d2287143b

1d2287143bc8b028f996fa9b7fb296130a0a8c60

parent: e3f8c2de0f

Unsigned

cmc <hello@cleberg.net> · 2026-03-23 01:42 UTC

feat: swipe actions on tickets, pastes, builds, and home dashboard

Layout: unified · split

Hutch/Views/Builds/BuildListView.swift +1
@@ -1,6 +1,7 @@
11import SwiftUI
22
33struct BuildListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
45 @Environment(AppState.self) private var appState
56 @State private var viewModel: BuildListViewModel?
67 @State private var showSubmitSheet = false
Hutch/Views/Builds/BuildListViewModel.swift +37
@@ -194,6 +194,35 @@ final class BuildListViewModel {
194194 }
195195 }
196196
197 func cancelJob(_ job: JobSummary) async {
198 guard job.status.isCancellable else { return }
199
200 do {
201 _ = try await client.execute(
202 service: .builds,
203 query: Self.cancelMutation,
204 variables: ["id": job.id],
205 responseType: CancelResponse.self
206 )
207 if let index = jobs.firstIndex(where: { $0.id == job.id }) {
208 let updated = JobSummary(
209 id: job.id,
210 created: job.created,
211 updated: job.updated,
212 status: .cancelled,
213 note: job.note,
214 tags: job.tags,
215 visibility: job.visibility,
216 image: job.image,
217 tasks: job.tasks
218 )
219 jobs[index] = updated
220 }
221 } catch {
222 self.error = error.userFacingMessage
223 }
224 }
225
197226 // MARK: - Private
198227
199228 private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
@@ -235,4 +264,12 @@ final class BuildListViewModel {
235264 hasMore = page.cursor != nil
236265 }
237266 }
267
268 private struct CancelResponse: Decodable, Sendable {
269 struct CancelResult: Decodable, Sendable {
270 let id: Int
271 }
272
273 let cancel: CancelResult
274 }
238275}
Hutch/Views/Home/HomeView.swift +87
@@ -1,6 +1,7 @@
11import SwiftUI
22
33struct HomeView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
45 @Environment(AppState.self) private var appState
56 @State private var viewModel: HomeViewModel?
67 private let previewLimit = 4
@@ -109,6 +110,23 @@ struct HomeView: View {
109110 } label: {
110111 HomeAssignedTicketRow(ticket: ticket)
111112 }
113 .swipeActions(edge: .leading, allowsFullSwipe: true) {
114 if swipeActionsEnabled {
115 ticketLeadingSwipeAction(ticket, viewModel: viewModel)
116 }
117 }
118 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
119 if swipeActionsEnabled {
120 Button {
121 Task {
122 await viewModel.unassignFromMe(ticket)
123 }
124 } label: {
125 Label("Unassign Me", systemImage: "person.badge.minus")
126 }
127 .tint(.orange)
128 }
129 }
112130 }
113131 }
114132 } header: {
@@ -142,6 +160,19 @@ struct HomeView: View {
142160 } label: {
143161 HomeBuildRow(build: build)
144162 }
163 .swipeActions(edge: .leading, allowsFullSwipe: true) {
164 if swipeActionsEnabled, build.job.status.isCancellable {
165 Button {
166 Task {
167 await viewModel.cancelBuild(build)
168 }
169 }
170 label: {
171 Label("Cancel", systemImage: "xmark.circle")
172 }
173 .tint(.red)
174 }
175 }
145176 }
146177 }
147178 } header: {
@@ -151,6 +182,32 @@ struct HomeView: View {
151182 }
152183 }
153184
185 @ViewBuilder
186 private func ticketLeadingSwipeAction(
187 _ ticket: HomeAssignedTicket,
188 viewModel: HomeViewModel
189 ) -> some View {
190 if ticket.ticket.status.isOpen {
191 Button {
192 Task {
193 await viewModel.resolveTicket(ticket)
194 }
195 } label: {
196 Label("Resolve", systemImage: "checkmark.circle")
197 }
198 .tint(.green)
199 } else {
200 Button {
201 Task {
202 await viewModel.reopenTicket(ticket)
203 }
204 } label: {
205 Label("Reopen", systemImage: "arrow.uturn.backward")
206 }
207 .tint(.blue)
208 }
209 }
210
154211}
155212
156213private struct HomeInboxToolbarIcon: View {
@@ -367,6 +424,7 @@ private struct HomeSectionActionHeader: View {
367424
368425private struct HomeAssignedTicketsListView: View {
369426 let viewModel: HomeViewModel
427 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
370428
371429 var body: some View {
372430 List {
@@ -382,6 +440,35 @@ private struct HomeAssignedTicketsListView: View {
382440 } label: {
383441 HomeAssignedTicketRow(ticket: ticket)
384442 }
443 .swipeActions(edge: .leading, allowsFullSwipe: true) {
444 if swipeActionsEnabled {
445 if ticket.ticket.status.isOpen {
446 Button {
447 Task { await viewModel.resolveTicket(ticket) }
448 } label: {
449 Label("Resolve", systemImage: "checkmark.circle")
450 }
451 .tint(.green)
452 } else {
453 Button {
454 Task { await viewModel.reopenTicket(ticket) }
455 } label: {
456 Label("Reopen", systemImage: "arrow.uturn.backward")
457 }
458 .tint(.blue)
459 }
460 }
461 }
462 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
463 if swipeActionsEnabled {
464 Button {
465 Task { await viewModel.unassignFromMe(ticket) }
466 } label: {
467 Label("Unassign Me", systemImage: "person.badge.minus")
468 }
469 .tint(.orange)
470 }
471 }
385472 }
386473
387474 if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty {
Hutch/Views/Home/HomeViewModel.swift +132 −18
@@ -137,15 +137,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable {
137137@MainActor
138138final class HomeViewModel {
139139 private(set) var projects: [Project] = []
140 private(set) var failedBuilds: [HomeBuildItem] = []
141 private(set) var assignedTickets: [HomeAssignedTicket] = []
142 private(set) var recentBuilds: [HomeBuildItem] = []
140 var assignedTickets: [HomeAssignedTicket] = []
141 var recentBuilds: [HomeBuildItem] = []
143142 private(set) var hasUnreadInboxThreads = false
144143 private(set) var isLoadingProjects = false
145 private(set) var isLoadingFailedBuilds = false
146144 private(set) var isLoadingAssignedTickets = false
147145 private(set) var isLoadingRecentBuilds = false
148 private(set) var failedBuildsError: String?
149146 private(set) var assignedTicketsError: String?
150147 private(set) var recentBuildsError: String?
151148
@@ -244,6 +241,26 @@ final class HomeViewModel {
244241 }
245242 """
246243
244 private static let updateTicketStatusMutation = """
245 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
246 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
247 eventType: __typename
248 }
249 }
250 """
251
252 private static let unassignUserMutation = """
253 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
254 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
255 }
256 """
257
258 private static let cancelBuildMutation = """
259 mutation cancel($id: Int!) {
260 cancel(jobId: $id) { id }
261 }
262 """
263
247264 init(currentUser: User, client: SRHTClient) {
248265 self.currentUser = currentUser
249266 self.client = client
@@ -252,10 +269,8 @@ final class HomeViewModel {
252269
253270 func loadDashboard() async {
254271 isLoadingProjects = true
255 isLoadingFailedBuilds = true
256272 isLoadingAssignedTickets = true
257273 isLoadingRecentBuilds = true
258 failedBuildsError = nil
259274 assignedTicketsError = nil
260275 recentBuildsError = nil
261276
@@ -279,16 +294,11 @@ final class HomeViewModel {
279294 case .success(let recentJobs):
280295 let buildItems = Self.buildItems(from: recentJobs)
281296 self.recentBuilds = buildItems
282 self.failedBuilds = Self.failedBuilds(from: buildItems)
283 self.failedBuildsError = nil
284297 self.recentBuildsError = nil
285298 case .failure(let error):
286299 self.recentBuilds = []
287 self.failedBuilds = []
288 self.failedBuildsError = error.userFacingMessage
289300 self.recentBuildsError = error.userFacingMessage
290301 }
291 isLoadingFailedBuilds = false
292302 isLoadingRecentBuilds = false
293303
294304 let assignedTicketsResult = await assignedTicketsTask
@@ -306,6 +316,70 @@ final class HomeViewModel {
306316 hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
307317 }
308318
319 func resolveTicket(_ ticket: HomeAssignedTicket) async {
320 let input: [String: any Sendable] = [
321 "status": TicketStatus.resolved.rawValue,
322 "resolution": TicketResolution.fixed.rawValue
323 ]
324 await performTicketStatusUpdate(ticket: ticket, input: input)
325 }
326
327 func reopenTicket(_ ticket: HomeAssignedTicket) async {
328 let input: [String: any Sendable] = [
329 "status": TicketStatus.reported.rawValue
330 ]
331 await performTicketStatusUpdate(ticket: ticket, input: input)
332 }
333
334 func unassignFromMe(_ ticket: HomeAssignedTicket) async {
335 do {
336 _ = try await client.execute(
337 service: .todo,
338 query: Self.unassignUserMutation,
339 variables: [
340 "trackerId": ticket.trackerId,
341 "ticketId": ticket.ticket.id,
342 "userId": currentUser.id
343 ],
344 responseType: UnassignResponse.self
345 )
346 assignedTickets.removeAll { $0.id == ticket.id }
347 } catch {
348 }
349 }
350
351 func cancelBuild(_ build: HomeBuildItem) async {
352 guard build.job.status.isCancellable else { return }
353
354 do {
355 _ = try await client.execute(
356 service: .builds,
357 query: Self.cancelBuildMutation,
358 variables: ["id": build.job.id],
359 responseType: CancelBuildResponse.self
360 )
361 if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
362 let updatedJob = JobSummary(
363 id: build.job.id,
364 created: build.job.created,
365 updated: build.job.updated,
366 status: .cancelled,
367 note: build.job.note,
368 tags: build.job.tags,
369 visibility: build.job.visibility,
370 image: build.job.image,
371 tasks: build.job.tasks
372 )
373 recentBuilds[index] = HomeBuildItem(
374 job: updatedJob,
375 repositoryName: build.repositoryName,
376 repositoryOwner: build.repositoryOwner
377 )
378 }
379 } catch {
380 }
381 }
382
309383 private func loadProjects() async -> Result<[Project], Error> {
310384 do {
311385 return .success(try await projectService.fetchProjects())
@@ -521,6 +595,26 @@ final class HomeViewModel {
521595 }
522596 }
523597
598 private func performTicketStatusUpdate(
599 ticket: HomeAssignedTicket,
600 input: [String: any Sendable]
601 ) async {
602 do {
603 _ = try await client.execute(
604 service: .todo,
605 query: Self.updateTicketStatusMutation,
606 variables: [
607 "trackerId": ticket.trackerId,
608 "ticketId": ticket.ticket.id,
609 "input": input
610 ],
611 responseType: StatusEventResponse.self
612 )
613 assignedTickets.removeAll { $0.id == ticket.id }
614 } catch {
615 }
616 }
617
524618 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
525619 jobs.map { job in
526620 let repository = primaryRepositoryReference(in: job.manifest)
@@ -532,8 +626,8 @@ final class HomeViewModel {
532626 }
533627 }
534628
535 nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] {
536 builds.filter { build in
629 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
630 buildItems(from: jobs).filter { build in
537631 switch build.job.status {
538632 case .failed, .timeout:
539633 true
@@ -543,10 +637,6 @@ final class HomeViewModel {
543637 }
544638 }
545639
546 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
547 failedBuilds(from: buildItems(from: jobs))
548 }
549
550640 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
551641 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
552642 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
@@ -598,6 +688,30 @@ final class HomeViewModel {
598688 }
599689 return trimmed
600690 }
691
692 private struct StatusEventResponse: Decodable, Sendable {
693 struct EventRef: Decodable, Sendable {
694 let eventType: String
695 }
696
697 let updateTicketStatus: EventRef
698 }
699
700 private struct UnassignResponse: Decodable, Sendable {
701 struct EventRef: Decodable, Sendable {
702 let id: Int
703 }
704
705 let unassignUser: EventRef
706 }
707
708 private struct CancelBuildResponse: Decodable, Sendable {
709 struct CancelResult: Decodable, Sendable {
710 let id: Int
711 }
712
713 let cancel: CancelResult
714 }
601715}
602716struct HomeJobPayload: Decodable, Sendable {
603717 let id: Int
Hutch/Views/Pastes/PasteListView.swift +76 −1
@@ -1,10 +1,12 @@
11import SwiftUI
22
33struct PasteListView: View {
4 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
45 @Environment(AppState.self) private var appState
56 @State private var viewModel: PasteListViewModel?
67 @State private var showCreatePasteSheet = false
78 @State private var createdPaste: Paste?
9 @State private var pasteToDelete: Paste?
810
911 var body: some View {
1012 Group {
@@ -68,10 +70,32 @@ struct PasteListView: View {
6870 @Bindable var vm = viewModel
6971
7072 List {
71 ForEach(viewModel.pastes) { paste in
73 ForEach(viewModel.filteredPastes) { paste in
7274 NavigationLink(value: paste) {
7375 PasteRowView(paste: paste)
7476 }
77 .swipeActions(edge: .leading, allowsFullSwipe: true) {
78 if swipeActionsEnabled {
79 Button {
80 Task { await viewModel.cycleVisibility(for: paste) }
81 } label: {
82 Label(
83 nextVisibilityLabel(for: paste.visibility),
84 systemImage: nextVisibilityIcon(for: paste.visibility)
85 )
86 }
87 .tint(nextVisibilityColor(for: paste.visibility))
88 }
89 }
90 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
91 if swipeActionsEnabled {
92 Button(role: .destructive) {
93 pasteToDelete = paste
94 } label: {
95 Label("Delete", systemImage: "trash")
96 }
97 }
98 }
7599 .task {
76100 await viewModel.loadMoreIfNeeded(currentItem: paste)
77101 }
@@ -115,6 +139,24 @@ struct PasteListView: View {
115139 await viewModel.loadPastes()
116140 }
117141 .srhtErrorBanner(error: $vm.error)
142 .alert("Delete Paste?", isPresented: Binding(
143 get: { pasteToDelete != nil },
144 set: { if !$0 { pasteToDelete = nil } }
145 )) {
146 Button("Cancel", role: .cancel) {
147 pasteToDelete = nil
148 }
149 Button("Delete", role: .destructive) {
150 if let paste = pasteToDelete {
151 pasteToDelete = nil
152 Task {
153 await viewModel.deletePaste(paste)
154 }
155 }
156 }
157 } message: {
158 Text("This paste will be permanently deleted from SourceHut.")
159 }
118160 .refreshable {
119161 await viewModel.loadPastes()
120162 }
@@ -130,6 +172,39 @@ struct PasteListView: View {
130172 )
131173 }
132174 }
175
176 private func nextVisibilityLabel(for visibility: Visibility) -> String {
177 switch visibility {
178 case .public:
179 return "Make Unlisted"
180 case .unlisted:
181 return "Make Private"
182 case .private:
183 return "Make Public"
184 }
185 }
186
187 private func nextVisibilityIcon(for visibility: Visibility) -> String {
188 switch visibility {
189 case .public:
190 return "eye.slash"
191 case .unlisted:
192 return "lock"
193 case .private:
194 return "globe"
195 }
196 }
197
198 private func nextVisibilityColor(for visibility: Visibility) -> Color {
199 switch visibility {
200 case .public:
201 return .orange
202 case .unlisted:
203 return .red
204 case .private:
205 return .green
206 }
207 }
133208}
134209
135210private struct PasteRowView: View {
Hutch/Views/Pastes/PasteListViewModel.swift +41
@@ -107,6 +107,47 @@ final class PasteListViewModel {
107107 }
108108 }
109109
110 func deletePaste(_ paste: Paste) async {
111 do {
112 _ = try await service.deletePaste(id: paste.id)
113 removePaste(id: paste.id)
114 } catch {
115 self.error = error.userFacingMessage
116 }
117 }
118
119 func cycleVisibility(for paste: Paste) async {
120 let next: Visibility
121 switch paste.visibility {
122 case .public:
123 next = .unlisted
124 case .unlisted:
125 next = .private
126 case .private:
127 next = .public
128 }
129
130 let original = pastes
131 if let index = pastes.firstIndex(where: { $0.id == paste.id }) {
132 pastes[index] = Paste(
133 id: paste.id,
134 created: paste.created,
135 visibility: next,
136 files: paste.files,
137 user: paste.user
138 )
139 }
140
141 do {
142 if let updated = try await service.updateVisibility(id: paste.id, visibility: next) {
143 upsertPaste(updated)
144 }
145 } catch {
146 pastes = original
147 self.error = error.userFacingMessage
148 }
149 }
150
110151 func upsertPaste(_ paste: Paste) {
111152 if let index = pastes.firstIndex(where: { $0.id == paste.id }) {
112153 pastes[index] = paste
Hutch/Views/Tickets/TicketListView.swift +208
@@ -6,10 +6,12 @@ struct TicketListView: View {
66 let trackerId: Int
77 let trackerRid: String
88
9 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
910 @Environment(AppState.self) private var appState
1011 @State private var viewModel: TicketListViewModel?
1112 @State private var showCreateTicketSheet = false
1213 @State private var createdTicket: TicketSummary?
14 @State private var labelEditorTicket: LabelEditorTicket?
1315
1416 var body: some View {
1517 Group {
@@ -45,6 +47,15 @@ struct TicketListView: View {
4547 }
4648 }
4749 }
50 .sheet(item: $labelEditorTicket) { item in
51 if let viewModel {
52 TicketLabelsSheet(
53 ticketId: item.id,
54 viewModel: viewModel
55 )
56 .presentationDetents([.medium])
57 }
58 }
4859 .navigationDestination(isPresented: Binding(
4960 get: { createdTicket != nil },
5061 set: { isPresented in
@@ -63,6 +74,7 @@ struct TicketListView: View {
6374 ownerUsername: ownerUsername,
6475 trackerName: trackerName,
6576 trackerId: trackerId,
77 trackerRid: trackerRid,
6678 client: appState.client
6779 )
6880 viewModel = vm
@@ -93,6 +105,16 @@ struct TicketListView: View {
93105 NavigationLink(value: ticket) {
94106 TicketRowView(ticket: ticket)
95107 }
108 .swipeActions(edge: .leading, allowsFullSwipe: true) {
109 if swipeActionsEnabled {
110 ticketLeadingSwipeAction(ticket, viewModel: viewModel)
111 }
112 }
113 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
114 if swipeActionsEnabled {
115 ticketTrailingSwipeActions(ticket, viewModel: viewModel)
116 }
117 }
96118 .task {
97119 await viewModel.loadMoreIfNeeded(currentItem: ticket)
98120 }
@@ -144,6 +166,106 @@ struct TicketListView: View {
144166 TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
145167 }
146168 }
169
170 @ViewBuilder
171 private func ticketLeadingSwipeAction(
172 _ ticket: TicketSummary,
173 viewModel: TicketListViewModel
174 ) -> some View {
175 if ticket.status.isOpen {
176 Button {
177 Task {
178 await resolveTicket(ticket, viewModel: viewModel)
179 }
180 } label: {
181 Label("Resolve", systemImage: "checkmark.circle")
182 }
183 .tint(.green)
184 } else {
185 Button {
186 Task {
187 await reopenTicket(ticket, viewModel: viewModel)
188 }
189 } label: {
190 Label("Reopen", systemImage: "arrow.uturn.backward")
191 }
192 .tint(.blue)
193 }
194 }
195
196 private func resolveTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async {
197 await viewModel.resolveTicket(ticket)
198 }
199
200 private func reopenTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async {
201 await viewModel.reopenTicket(ticket)
202 }
203
204 @ViewBuilder
205 private func ticketTrailingSwipeActions(
206 _ ticket: TicketSummary,
207 viewModel: TicketListViewModel
208 ) -> some View {
209 Button {
210 labelEditorTicket = LabelEditorTicket(id: ticket.id)
211 Task { await viewModel.loadTrackerLabels() }
212 } label: {
213 Label("Edit Labels", systemImage: "tag")
214 }
215 .tint(.purple)
216
217 if let currentUser = appState.currentUser {
218 let isAssigned = ticket.assignees.contains { assignee in
219 matchesAssignee(assignee, user: currentUser)
220 }
221
222 if isAssigned {
223 Button {
224 Task {
225 await viewModel.unassignFromMe(ticket: ticket, user: currentUser)
226 }
227 } label: {
228 Label("Unassign Me", systemImage: "person.badge.minus")
229 }
230 .tint(.orange)
231 } else {
232 Button {
233 Task {
234 await viewModel.assignToMe(ticket: ticket, user: currentUser)
235 }
236 } label: {
237 Label("Assign Me", systemImage: "person.badge.plus")
238 }
239 .tint(.cyan)
240 }
241 }
242 }
243
244 private func matchesAssignee(_ entity: Entity, user: User) -> Bool {
245 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
246 let userCanonical = normalizedCanonicalName(user.canonicalName)
247 if assigneeCanonical == userCanonical {
248 return true
249 }
250 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
251 }
252
253 private func normalizedCanonicalName(_ value: String) -> String {
254 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
255 if trimmed.hasPrefix("~") {
256 return trimmed
257 }
258 return "~\(trimmed)"
259 }
260
261 private func normalizedUsername(_ value: String) -> String {
262 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
263 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
264 }
265}
266
267private struct LabelEditorTicket: Identifiable {
268 let id: Int
147269}
148270
149271private struct CreateTicketSheet: View {
@@ -191,6 +313,92 @@ private struct CreateTicketSheet: View {
191313 }
192314}
193315
316private struct TicketLabelsSheet: View {
317 let ticketId: Int
318 let viewModel: TicketListViewModel
319
320 @Environment(\.dismiss) private var dismiss
321
322 var body: some View {
323 NavigationStack {
324 Group {
325 if let ticket = viewModel.ticket(withId: ticketId) {
326 if viewModel.trackerLabels.isEmpty {
327 if viewModel.isPerformingAction {
328 ProgressView()
329 } else {
330 ContentUnavailableView(
331 "No Labels",
332 systemImage: "tag",
333 description: Text("This tracker has no labels defined.")
334 )
335 }
336 } else {
337 List {
338 ForEach(viewModel.trackerLabels) { label in
339 TicketListLabelToggleRow(
340 label: label,
341 isApplied: ticket.labels.contains(where: { $0.id == label.id }),
342 isLoading: viewModel.isPerformingAction
343 ) { shouldApply in
344 Task {
345 if shouldApply {
346 await viewModel.labelTicket(ticket, label: label)
347 } else {
348 await viewModel.unlabelTicket(ticket, label: label)
349 }
350 }
351 }
352 }
353 }
354 }
355 } else {
356 ContentUnavailableView(
357 "Ticket Unavailable",
358 systemImage: "ticket",
359 description: Text("This ticket is no longer in the current list.")
360 )
361 }
362 }
363 .navigationTitle("Labels")
364 .navigationBarTitleDisplayMode(.inline)
365 .toolbar {
366 ToolbarItem(placement: .cancellationAction) {
367 Button("Done") { dismiss() }
368 }
369 }
370 .task {
371 if viewModel.trackerLabels.isEmpty {
372 await viewModel.loadTrackerLabels()
373 }
374 }
375 }
376 }
377}
378
379private struct TicketListLabelToggleRow: View {
380 let label: TicketLabel
381 let isApplied: Bool
382 let isLoading: Bool
383 let onToggle: (Bool) -> Void
384
385 var body: some View {
386 Button {
387 onToggle(!isApplied)
388 } label: {
389 HStack {
390 LabelPill(label: label)
391 Spacer()
392 if isApplied {
393 Image(systemName: "checkmark")
394 .foregroundStyle(.blue)
395 }
396 }
397 }
398 .disabled(isLoading)
399 }
400}
401
194402// MARK: - Ticket Row
195403
196404private struct TicketRowView: View {
Hutch/Views/Tickets/TicketListViewModel.swift +305
@@ -19,6 +19,48 @@ private struct TicketsPage: Decodable, Sendable {
1919 let cursor: String?
2020}
2121
22private struct AssignmentMutationResponse: Decodable, Sendable {
23 struct EventRef: Decodable, Sendable {
24 let id: Int
25 }
26
27 let assignUser: EventRef?
28 let unassignUser: EventRef?
29}
30
31private struct LabelMutationResponse: Decodable, Sendable {
32 struct EventRef: Decodable, Sendable {
33 let id: Int
34 }
35
36 let labelTicket: EventRef?
37 let unlabelTicket: EventRef?
38}
39
40private struct TrackerLabelsResponse: Decodable, Sendable {
41 let user: UserTrackerLabelsWrapper
42}
43
44private struct UserTrackerLabelsWrapper: Decodable, Sendable {
45 let tracker: TrackerLabelsWrapper
46}
47
48private struct TrackerLabelsWrapper: Decodable, Sendable {
49 let labels: LabelsPage
50}
51
52private struct LabelsPage: Decodable, Sendable {
53 let results: [TicketLabel]
54}
55
56private struct UpdateStatusResponse: Decodable, Sendable {
57 let updateTicketStatus: MutationEventRef
58}
59
60private struct MutationEventRef: Decodable, Sendable {
61 let eventType: String
62}
63
2264// MARK: - Filter
2365
2466enum TicketFilter: String, CaseIterable, Sendable {
@@ -260,8 +302,233 @@ final class TicketListViewModel {
260302 }
261303 }
262304
305 func resolveTicket(_ ticket: TicketSummary) async {
306 let input: [String: any Sendable] = [
307 "status": TicketStatus.resolved.rawValue,
308 "resolution": TicketResolution.fixed.rawValue
309 ]
310 await performStatusUpdate(ticket: ticket, input: input)
311 }
312
313 func reopenTicket(_ ticket: TicketSummary) async {
314 let input: [String: any Sendable] = [
315 "status": TicketStatus.reported.rawValue
316 ]
317 await performStatusUpdate(ticket: ticket, input: input)
318 }
319
320 func assignToMe(ticket: TicketSummary, user: User) async {
321 guard !isPerformingAction else { return }
322 isPerformingAction = true
323 error = nil
324
325 let original = tickets
326 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
327 let entity = Entity(canonicalName: user.canonicalName)
328 let updated = TicketSummary(
329 id: ticket.id,
330 title: ticket.title,
331 status: ticket.status,
332 resolution: ticket.resolution,
333 created: ticket.created,
334 submitter: ticket.submitter,
335 labels: ticket.labels,
336 assignees: ticket.assignees + [entity]
337 )
338 tickets[index] = updated
339 }
340
341 do {
342 _ = try await client.execute(
343 service: .todo,
344 query: Self.assignUserMutation,
345 variables: [
346 "trackerId": trackerId,
347 "ticketId": ticket.id,
348 "userId": user.id
349 ],
350 responseType: AssignmentMutationResponse.self
351 )
352 } catch {
353 tickets = original
354 self.error = error.userFacingMessage
355 }
356
357 isPerformingAction = false
358 }
359
360 func unassignFromMe(ticket: TicketSummary, user: User) async {
361 guard !isPerformingAction else { return }
362 isPerformingAction = true
363 error = nil
364
365 let original = tickets
366 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
367 let filtered = ticket.assignees.filter { assignee in
368 !Self.matchesAssignee(assignee, user: user)
369 }
370 let updated = TicketSummary(
371 id: ticket.id,
372 title: ticket.title,
373 status: ticket.status,
374 resolution: ticket.resolution,
375 created: ticket.created,
376 submitter: ticket.submitter,
377 labels: ticket.labels,
378 assignees: filtered
379 )
380 tickets[index] = updated
381 }
382
383 do {
384 _ = try await client.execute(
385 service: .todo,
386 query: Self.unassignUserMutation,
387 variables: [
388 "trackerId": trackerId,
389 "ticketId": ticket.id,
390 "userId": user.id
391 ],
392 responseType: AssignmentMutationResponse.self
393 )
394 } catch {
395 tickets = original
396 self.error = error.userFacingMessage
397 }
398
399 isPerformingAction = false
400 }
401
402 func loadTrackerLabels() async {
403 do {
404 let result = try await client.execute(
405 service: .todo,
406 query: Self.trackerLabelsQuery,
407 variables: [
408 "owner": ownerUsername,
409 "tracker": trackerName
410 ],
411 responseType: TrackerLabelsResponse.self
412 )
413 trackerLabels = result.user.tracker.labels.results
414 } catch {
415 self.error = error.userFacingMessage
416 }
417 }
418
419 func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
420 guard !isPerformingAction else { return }
421 isPerformingAction = true
422 error = nil
423
424 let original = tickets
425 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
426 let updated = TicketSummary(
427 id: ticket.id,
428 title: ticket.title,
429 status: ticket.status,
430 resolution: ticket.resolution,
431 created: ticket.created,
432 submitter: ticket.submitter,
433 labels: ticket.labels + [label],
434 assignees: ticket.assignees
435 )
436 tickets[index] = updated
437 }
438
439 do {
440 _ = try await client.execute(
441 service: .todo,
442 query: Self.labelTicketMutation,
443 variables: [
444 "trackerId": trackerId,
445 "ticketId": ticket.id,
446 "labelId": label.id
447 ],
448 responseType: LabelMutationResponse.self
449 )
450 } catch {
451 tickets = original
452 self.error = error.userFacingMessage
453 }
454
455 isPerformingAction = false
456 }
457
458 func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
459 guard !isPerformingAction else { return }
460 isPerformingAction = true
461 error = nil
462
463 let original = tickets
464 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
465 let filtered = ticket.labels.filter { $0.id != label.id }
466 let updated = TicketSummary(
467 id: ticket.id,
468 title: ticket.title,
469 status: ticket.status,
470 resolution: ticket.resolution,
471 created: ticket.created,
472 submitter: ticket.submitter,
473 labels: filtered,
474 assignees: ticket.assignees
475 )
476 tickets[index] = updated
477 }
478
479 do {
480 _ = try await client.execute(
481 service: .todo,
482 query: Self.unlabelTicketMutation,
483 variables: [
484 "trackerId": trackerId,
485 "ticketId": ticket.id,
486 "labelId": label.id
487 ],
488 responseType: LabelMutationResponse.self
489 )
490 } catch {
491 tickets = original
492 self.error = error.userFacingMessage
493 }
494
495 isPerformingAction = false
496 }
497
498 func ticket(withId ticketId: Int) -> TicketSummary? {
499 tickets.first(where: { $0.id == ticketId })
500 }
501
263502 // MARK: - Private
264503
504 private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
505 guard !isPerformingAction else { return }
506 isPerformingAction = true
507 error = nil
508
509 do {
510 let variables: [String: any Sendable] = [
511 "trackerId": trackerId,
512 "ticketId": ticket.id,
513 "input": input
514 ]
515 let result = try await client.execute(
516 service: .todo,
517 query: Self.updateStatusMutation,
518 variables: variables,
519 responseType: UpdateStatusResponse.self
520 )
521 _ = result.updateTicketStatus
522 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
523 tickets[index] = updatedTicket(from: ticket, input: input)
524 }
525 } catch {
526 self.error = error.userFacingMessage
527 }
528
529 isPerformingAction = false
530 }
531
265532 private func fetchPage(cursor: String?) async throws -> TicketsPage {
266533 var variables: [String: any Sendable] = [
267534 "owner": ownerUsername,
@@ -282,4 +549,42 @@ final class TicketListViewModel {
282549 private struct SubmitTicketResponse: Decodable, Sendable {
283550 let submitTicket: TicketSummary
284551 }
552
553 private static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
554 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
555 let userCanonical = normalizedCanonicalName(user.canonicalName)
556 if assigneeCanonical == userCanonical {
557 return true
558 }
559 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
560 }
561
562 private static func normalizedCanonicalName(_ value: String) -> String {
563 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
564 if trimmed.hasPrefix("~") {
565 return trimmed
566 }
567 return "~\(trimmed)"
568 }
569
570 private static func normalizedUsername(_ value: String) -> String {
571 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
572 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
573 }
574
575 private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary {
576 let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status
577 let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:))
578
579 return TicketSummary(
580 id: ticket.id,
581 title: ticket.title,
582 status: updatedStatus,
583 resolution: updatedStatus == .resolved ? updatedResolution : nil,
584 created: ticket.created,
585 submitter: ticket.submitter,
586 labels: ticket.labels,
587 assignees: ticket.assignees
588 )
589 }
285590}
HutchTests/TicketListViewModelTests.swift added +116
@@ -0,0 +1,116 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct TicketListViewModelTests {
6
7 @Test
8 func filteredTicketsReturnsStatusFilteredTicketsWhenSearchTextIsEmpty() {
9 let tickets = [
10 makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: []),
11 makeTicket(id: 2, title: "Already fixed", status: .resolved, submitter: "~owner", labels: [])
12 ]
13
14 let filtered = filterTickets(tickets, filter: .open, query: "")
15
16 #expect(filtered.map(\.id) == [1])
17 }
18
19 @Test
20 func filteredTicketsMatchesTitleAndTicketId() {
21 let tickets = [
22 makeTicket(id: 42, title: "Crash on launch", status: .reported, submitter: "~owner", labels: []),
23 makeTicket(id: 99, title: "Settings polish", status: .reported, submitter: "~owner", labels: [])
24 ]
25
26 let titleMatches = filterTickets(tickets, filter: .all, query: "settings")
27 let idMatches = filterTickets(tickets, filter: .all, query: "42")
28
29 #expect(titleMatches.map(\.id) == [99])
30 #expect(idMatches.map(\.id) == [42])
31 }
32
33 @Test
34 func filteredTicketsMatchesSubmitterAndLabels() {
35 let tickets = [
36 makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: [makeLabel(id: 1, name: "bug")]),
37 makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")])
38 ]
39
40 let submitterMatches = filterTickets(tickets, filter: .all, query: "~triage")
41 let labelMatches = filterTickets(tickets, filter: .all, query: "bug")
42
43 #expect(submitterMatches.map(\.id) == [2])
44 #expect(labelMatches.map(\.id) == [1])
45 }
46
47 @Test
48 @MainActor
49 func resolveTicketInputHasCorrectStatusAndDefaultResolution() {
50 let input: [String: any Sendable] = [
51 "status": TicketStatus.resolved.rawValue,
52 "resolution": TicketResolution.fixed.rawValue
53 ]
54 #expect(input["status"] as? String == "resolved")
55 #expect(input["resolution"] as? String == "fixed")
56 }
57
58 @Test
59 @MainActor
60 func reopenTicketInputOmitsResolution() {
61 let input: [String: any Sendable] = [
62 "status": TicketStatus.reported.rawValue
63 ]
64 #expect(input["status"] as? String == "reported")
65 #expect(input["resolution"] == nil)
66 }
67
68 private func filterTickets(_ tickets: [TicketSummary], filter: TicketFilter, query: String) -> [TicketSummary] {
69 let statusFiltered: [TicketSummary]
70 switch filter {
71 case .open:
72 statusFiltered = tickets.filter { $0.status.isOpen }
73 case .resolved:
74 statusFiltered = tickets.filter { !$0.status.isOpen }
75 case .all:
76 statusFiltered = tickets
77 }
78
79 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
80 guard !q.isEmpty else { return statusFiltered }
81 return statusFiltered.filter {
82 String($0.id).contains(q) ||
83 $0.title.lowercased().contains(q) ||
84 $0.submitter.canonicalName.lowercased().contains(q) ||
85 $0.labels.contains { $0.name.lowercased().contains(q) }
86 }
87 }
88
89 private func makeTicket(
90 id: Int,
91 title: String,
92 status: TicketStatus,
93 submitter: String,
94 labels: [TicketLabel]
95 ) -> TicketSummary {
96 TicketSummary(
97 id: id,
98 title: title,
99 status: status,
100 resolution: status == .resolved ? .fixed : nil,
101 created: Date(),
102 submitter: Entity(canonicalName: submitter),
103 labels: labels,
104 assignees: []
105 )
106 }
107
108 private func makeLabel(id: Int, name: String) -> TicketLabel {
109 TicketLabel(
110 id: id,
111 name: name,
112 backgroundColor: "#000000",
113 foregroundColor: "#ffffff"
114 )
115 }
116}