Commit 1d2287143b
Unsigned
Layout: unified · split
Hutch/Views/Builds/BuildListView.swift +1
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct BuildListView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | |
| 4 | 5 | @Environment(AppState.self) private var appState |
| 5 | 6 | @State private var viewModel: BuildListViewModel? |
| 6 | 7 | @State private var showSubmitSheet = false |
Hutch/Views/Builds/BuildListViewModel.swift +37
| @@ -194,6 +194,35 @@ final class BuildListViewModel { | ||
| 194 | 194 | } |
| 195 | 195 | } |
| 196 | 196 | |
| 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 | ||
| 197 | 226 | // MARK: - Private |
| 198 | 227 | |
| 199 | 228 | private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { |
| @@ -235,4 +264,12 @@ final class BuildListViewModel { | ||
| 235 | 264 | hasMore = page.cursor != nil |
| 236 | 265 | } |
| 237 | 266 | } |
| 267 | ||
| 268 | private struct CancelResponse: Decodable, Sendable { | |
| 269 | struct CancelResult: Decodable, Sendable { | |
| 270 | let id: Int | |
| 271 | } | |
| 272 | ||
| 273 | let cancel: CancelResult | |
| 274 | } | |
| 238 | 275 | } |
Hutch/Views/Home/HomeView.swift +87
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct HomeView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | |
| 4 | 5 | @Environment(AppState.self) private var appState |
| 5 | 6 | @State private var viewModel: HomeViewModel? |
| 6 | 7 | private let previewLimit = 4 |
| @@ -109,6 +110,23 @@ struct HomeView: View { | ||
| 109 | 110 | } label: { |
| 110 | 111 | HomeAssignedTicketRow(ticket: ticket) |
| 111 | 112 | } |
| 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 | } | |
| 112 | 130 | } |
| 113 | 131 | } |
| 114 | 132 | } header: { |
| @@ -142,6 +160,19 @@ struct HomeView: View { | ||
| 142 | 160 | } label: { |
| 143 | 161 | HomeBuildRow(build: build) |
| 144 | 162 | } |
| 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 | } | |
| 145 | 176 | } |
| 146 | 177 | } |
| 147 | 178 | } header: { |
| @@ -151,6 +182,32 @@ struct HomeView: View { | ||
| 151 | 182 | } |
| 152 | 183 | } |
| 153 | 184 | |
| 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 | ||
| 154 | 211 | } |
| 155 | 212 | |
| 156 | 213 | private struct HomeInboxToolbarIcon: View { |
| @@ -367,6 +424,7 @@ private struct HomeSectionActionHeader: View { | ||
| 367 | 424 | |
| 368 | 425 | private struct HomeAssignedTicketsListView: View { |
| 369 | 426 | let viewModel: HomeViewModel |
| 427 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | |
| 370 | 428 | |
| 371 | 429 | var body: some View { |
| 372 | 430 | List { |
| @@ -382,6 +440,35 @@ private struct HomeAssignedTicketsListView: View { | ||
| 382 | 440 | } label: { |
| 383 | 441 | HomeAssignedTicketRow(ticket: ticket) |
| 384 | 442 | } |
| 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 | } | |
| 385 | 472 | } |
| 386 | 473 | |
| 387 | 474 | if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { |
Hutch/Views/Home/HomeViewModel.swift +132 −18
| @@ -137,15 +137,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable { | ||
| 137 | 137 | @MainActor |
| 138 | 138 | final class HomeViewModel { |
| 139 | 139 | 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] = [] | |
| 143 | 142 | private(set) var hasUnreadInboxThreads = false |
| 144 | 143 | private(set) var isLoadingProjects = false |
| 145 | private(set) var isLoadingFailedBuilds = false | |
| 146 | 144 | private(set) var isLoadingAssignedTickets = false |
| 147 | 145 | private(set) var isLoadingRecentBuilds = false |
| 148 | private(set) var failedBuildsError: String? | |
| 149 | 146 | private(set) var assignedTicketsError: String? |
| 150 | 147 | private(set) var recentBuildsError: String? |
| 151 | 148 | |
| @@ -244,6 +241,26 @@ final class HomeViewModel { | ||
| 244 | 241 | } |
| 245 | 242 | """ |
| 246 | 243 | |
| 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 | ||
| 247 | 264 | init(currentUser: User, client: SRHTClient) { |
| 248 | 265 | self.currentUser = currentUser |
| 249 | 266 | self.client = client |
| @@ -252,10 +269,8 @@ final class HomeViewModel { | ||
| 252 | 269 | |
| 253 | 270 | func loadDashboard() async { |
| 254 | 271 | isLoadingProjects = true |
| 255 | isLoadingFailedBuilds = true | |
| 256 | 272 | isLoadingAssignedTickets = true |
| 257 | 273 | isLoadingRecentBuilds = true |
| 258 | failedBuildsError = nil | |
| 259 | 274 | assignedTicketsError = nil |
| 260 | 275 | recentBuildsError = nil |
| 261 | 276 | |
| @@ -279,16 +294,11 @@ final class HomeViewModel { | ||
| 279 | 294 | case .success(let recentJobs): |
| 280 | 295 | let buildItems = Self.buildItems(from: recentJobs) |
| 281 | 296 | self.recentBuilds = buildItems |
| 282 | self.failedBuilds = Self.failedBuilds(from: buildItems) | |
| 283 | self.failedBuildsError = nil | |
| 284 | 297 | self.recentBuildsError = nil |
| 285 | 298 | case .failure(let error): |
| 286 | 299 | self.recentBuilds = [] |
| 287 | self.failedBuilds = [] | |
| 288 | self.failedBuildsError = error.userFacingMessage | |
| 289 | 300 | self.recentBuildsError = error.userFacingMessage |
| 290 | 301 | } |
| 291 | isLoadingFailedBuilds = false | |
| 292 | 302 | isLoadingRecentBuilds = false |
| 293 | 303 | |
| 294 | 304 | let assignedTicketsResult = await assignedTicketsTask |
| @@ -306,6 +316,70 @@ final class HomeViewModel { | ||
| 306 | 316 | hasUnreadInboxThreads = (await inboxUnreadTask) ?? false |
| 307 | 317 | } |
| 308 | 318 | |
| 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 | ||
| 309 | 383 | private func loadProjects() async -> Result<[Project], Error> { |
| 310 | 384 | do { |
| 311 | 385 | return .success(try await projectService.fetchProjects()) |
| @@ -521,6 +595,26 @@ final class HomeViewModel { | ||
| 521 | 595 | } |
| 522 | 596 | } |
| 523 | 597 | |
| 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 | ||
| 524 | 618 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { |
| 525 | 619 | jobs.map { job in |
| 526 | 620 | let repository = primaryRepositoryReference(in: job.manifest) |
| @@ -532,8 +626,8 @@ final class HomeViewModel { | ||
| 532 | 626 | } |
| 533 | 627 | } |
| 534 | 628 | |
| 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 | |
| 537 | 631 | switch build.job.status { |
| 538 | 632 | case .failed, .timeout: |
| 539 | 633 | true |
| @@ -543,10 +637,6 @@ final class HomeViewModel { | ||
| 543 | 637 | } |
| 544 | 638 | } |
| 545 | 639 | |
| 546 | nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | |
| 547 | failedBuilds(from: buildItems(from: jobs)) | |
| 548 | } | |
| 549 | ||
| 550 | 640 | nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { |
| 551 | 641 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) |
| 552 | 642 | let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) |
| @@ -598,6 +688,30 @@ final class HomeViewModel { | ||
| 598 | 688 | } |
| 599 | 689 | return trimmed |
| 600 | 690 | } |
| 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 | } | |
| 601 | 715 | } |
| 602 | 716 | struct HomeJobPayload: Decodable, Sendable { |
| 603 | 717 | let id: Int |
Hutch/Views/Pastes/PasteListView.swift +76 −1
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | 2 | |
| 3 | 3 | struct PasteListView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | |
| 4 | 5 | @Environment(AppState.self) private var appState |
| 5 | 6 | @State private var viewModel: PasteListViewModel? |
| 6 | 7 | @State private var showCreatePasteSheet = false |
| 7 | 8 | @State private var createdPaste: Paste? |
| 9 | @State private var pasteToDelete: Paste? | |
| 8 | 10 | |
| 9 | 11 | var body: some View { |
| 10 | 12 | Group { |
| @@ -68,10 +70,32 @@ struct PasteListView: View { | ||
| 68 | 70 | @Bindable var vm = viewModel |
| 69 | 71 | |
| 70 | 72 | List { |
| 71 | ForEach(viewModel.pastes) { paste in | |
| 73 | ForEach(viewModel.filteredPastes) { paste in | |
| 72 | 74 | NavigationLink(value: paste) { |
| 73 | 75 | PasteRowView(paste: paste) |
| 74 | 76 | } |
| 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 | } | |
| 75 | 99 | .task { |
| 76 | 100 | await viewModel.loadMoreIfNeeded(currentItem: paste) |
| 77 | 101 | } |
| @@ -115,6 +139,24 @@ struct PasteListView: View { | ||
| 115 | 139 | await viewModel.loadPastes() |
| 116 | 140 | } |
| 117 | 141 | .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 | } | |
| 118 | 160 | .refreshable { |
| 119 | 161 | await viewModel.loadPastes() |
| 120 | 162 | } |
| @@ -130,6 +172,39 @@ struct PasteListView: View { | ||
| 130 | 172 | ) |
| 131 | 173 | } |
| 132 | 174 | } |
| 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 | } | |
| 133 | 208 | } |
| 134 | 209 | |
| 135 | 210 | private struct PasteRowView: View { |
Hutch/Views/Pastes/PasteListViewModel.swift +41
| @@ -107,6 +107,47 @@ final class PasteListViewModel { | ||
| 107 | 107 | } |
| 108 | 108 | } |
| 109 | 109 | |
| 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 | ||
| 110 | 151 | func upsertPaste(_ paste: Paste) { |
| 111 | 152 | if let index = pastes.firstIndex(where: { $0.id == paste.id }) { |
| 112 | 153 | pastes[index] = paste |
Hutch/Views/Tickets/TicketListView.swift +208
| @@ -6,10 +6,12 @@ struct TicketListView: View { | ||
| 6 | 6 | let trackerId: Int |
| 7 | 7 | let trackerRid: String |
| 8 | 8 | |
| 9 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | |
| 9 | 10 | @Environment(AppState.self) private var appState |
| 10 | 11 | @State private var viewModel: TicketListViewModel? |
| 11 | 12 | @State private var showCreateTicketSheet = false |
| 12 | 13 | @State private var createdTicket: TicketSummary? |
| 14 | @State private var labelEditorTicket: LabelEditorTicket? | |
| 13 | 15 | |
| 14 | 16 | var body: some View { |
| 15 | 17 | Group { |
| @@ -45,6 +47,15 @@ struct TicketListView: View { | ||
| 45 | 47 | } |
| 46 | 48 | } |
| 47 | 49 | } |
| 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 | } | |
| 48 | 59 | .navigationDestination(isPresented: Binding( |
| 49 | 60 | get: { createdTicket != nil }, |
| 50 | 61 | set: { isPresented in |
| @@ -63,6 +74,7 @@ struct TicketListView: View { | ||
| 63 | 74 | ownerUsername: ownerUsername, |
| 64 | 75 | trackerName: trackerName, |
| 65 | 76 | trackerId: trackerId, |
| 77 | trackerRid: trackerRid, | |
| 66 | 78 | client: appState.client |
| 67 | 79 | ) |
| 68 | 80 | viewModel = vm |
| @@ -93,6 +105,16 @@ struct TicketListView: View { | ||
| 93 | 105 | NavigationLink(value: ticket) { |
| 94 | 106 | TicketRowView(ticket: ticket) |
| 95 | 107 | } |
| 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 | } | |
| 96 | 118 | .task { |
| 97 | 119 | await viewModel.loadMoreIfNeeded(currentItem: ticket) |
| 98 | 120 | } |
| @@ -144,6 +166,106 @@ struct TicketListView: View { | ||
| 144 | 166 | TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id) |
| 145 | 167 | } |
| 146 | 168 | } |
| 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 | ||
| 267 | private struct LabelEditorTicket: Identifiable { | |
| 268 | let id: Int | |
| 147 | 269 | } |
| 148 | 270 | |
| 149 | 271 | private struct CreateTicketSheet: View { |
| @@ -191,6 +313,92 @@ private struct CreateTicketSheet: View { | ||
| 191 | 313 | } |
| 192 | 314 | } |
| 193 | 315 | |
| 316 | private 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 | ||
| 379 | private 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 | ||
| 194 | 402 | // MARK: - Ticket Row |
| 195 | 403 | |
| 196 | 404 | private struct TicketRowView: View { |
Hutch/Views/Tickets/TicketListViewModel.swift +305
| @@ -19,6 +19,48 @@ private struct TicketsPage: Decodable, Sendable { | ||
| 19 | 19 | let cursor: String? |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | private 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 | ||
| 31 | private 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 | ||
| 40 | private struct TrackerLabelsResponse: Decodable, Sendable { | |
| 41 | let user: UserTrackerLabelsWrapper | |
| 42 | } | |
| 43 | ||
| 44 | private struct UserTrackerLabelsWrapper: Decodable, Sendable { | |
| 45 | let tracker: TrackerLabelsWrapper | |
| 46 | } | |
| 47 | ||
| 48 | private struct TrackerLabelsWrapper: Decodable, Sendable { | |
| 49 | let labels: LabelsPage | |
| 50 | } | |
| 51 | ||
| 52 | private struct LabelsPage: Decodable, Sendable { | |
| 53 | let results: [TicketLabel] | |
| 54 | } | |
| 55 | ||
| 56 | private struct UpdateStatusResponse: Decodable, Sendable { | |
| 57 | let updateTicketStatus: MutationEventRef | |
| 58 | } | |
| 59 | ||
| 60 | private struct MutationEventRef: Decodable, Sendable { | |
| 61 | let eventType: String | |
| 62 | } | |
| 63 | ||
| 22 | 64 | // MARK: - Filter |
| 23 | 65 | |
| 24 | 66 | enum TicketFilter: String, CaseIterable, Sendable { |
| @@ -260,8 +302,233 @@ final class TicketListViewModel { | ||
| 260 | 302 | } |
| 261 | 303 | } |
| 262 | 304 | |
| 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 | ||
| 263 | 502 | // MARK: - Private |
| 264 | 503 | |
| 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 | ||
| 265 | 532 | private func fetchPage(cursor: String?) async throws -> TicketsPage { |
| 266 | 533 | var variables: [String: any Sendable] = [ |
| 267 | 534 | "owner": ownerUsername, |
| @@ -282,4 +549,42 @@ final class TicketListViewModel { | ||
| 282 | 549 | private struct SubmitTicketResponse: Decodable, Sendable { |
| 283 | 550 | let submitTicket: TicketSummary |
| 284 | 551 | } |
| 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 | } | |
| 285 | 590 | } |
HutchTests/TicketListViewModelTests.swift added +116
| @@ -0,0 +1,116 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | struct 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 | } | |