Commit 1d2287143b
Unsigned
Layout: unified · split
Hutch/Views/Builds/BuildListView.swift +1
| @@ -1,6 +1,7 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct BuildListView: View { | 3 | struct BuildListView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | ||
| 4 | @Environment(AppState.self) private var appState | 5 | @Environment(AppState.self) private var appState |
| 5 | @State private var viewModel: BuildListViewModel? | 6 | @State private var viewModel: BuildListViewModel? |
| 6 | @State private var showSubmitSheet = false | 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 | // MARK: - Private | 226 | // MARK: - Private |
| 198 | 227 | ||
| 199 | private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { | 228 | private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { |
| @@ -235,4 +264,12 @@ final class BuildListViewModel { | |||
| 235 | hasMore = page.cursor != nil | 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 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct HomeView: View { | 3 | struct HomeView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | ||
| 4 | @Environment(AppState.self) private var appState | 5 | @Environment(AppState.self) private var appState |
| 5 | @State private var viewModel: HomeViewModel? | 6 | @State private var viewModel: HomeViewModel? |
| 6 | private let previewLimit = 4 | 7 | private let previewLimit = 4 |
| @@ -109,6 +110,23 @@ struct HomeView: View { | |||
| 109 | } label: { | 110 | } label: { |
| 110 | HomeAssignedTicketRow(ticket: ticket) | 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 | } header: { | 132 | } header: { |
| @@ -142,6 +160,19 @@ struct HomeView: View { | |||
| 142 | } label: { | 160 | } label: { |
| 143 | HomeBuildRow(build: build) | 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 | } header: { | 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 | private struct HomeInboxToolbarIcon: View { | 213 | private struct HomeInboxToolbarIcon: View { |
| @@ -367,6 +424,7 @@ private struct HomeSectionActionHeader: View { | |||
| 367 | 424 | ||
| 368 | private struct HomeAssignedTicketsListView: View { | 425 | private struct HomeAssignedTicketsListView: View { |
| 369 | let viewModel: HomeViewModel | 426 | let viewModel: HomeViewModel |
| 427 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | ||
| 370 | 428 | ||
| 371 | var body: some View { | 429 | var body: some View { |
| 372 | List { | 430 | List { |
| @@ -382,6 +440,35 @@ private struct HomeAssignedTicketsListView: View { | |||
| 382 | } label: { | 440 | } label: { |
| 383 | HomeAssignedTicketRow(ticket: ticket) | 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 | if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { | 474 | if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { |
Hutch/Views/Home/HomeViewModel.swift +132 −18
| @@ -137,15 +137,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable { | |||
| 137 | @MainActor | 137 | @MainActor |
| 138 | final class HomeViewModel { | 138 | final class HomeViewModel { |
| 139 | private(set) var projects: [Project] = [] | 139 | private(set) var projects: [Project] = [] |
| 140 | private(set) var failedBuilds: [HomeBuildItem] = [] | 140 | var assignedTickets: [HomeAssignedTicket] = [] |
| 141 | private(set) var assignedTickets: [HomeAssignedTicket] = [] | 141 | var recentBuilds: [HomeBuildItem] = [] |
| 142 | private(set) var recentBuilds: [HomeBuildItem] = [] | ||
| 143 | private(set) var hasUnreadInboxThreads = false | 142 | private(set) var hasUnreadInboxThreads = false |
| 144 | private(set) var isLoadingProjects = false | 143 | private(set) var isLoadingProjects = false |
| 145 | private(set) var isLoadingFailedBuilds = false | ||
| 146 | private(set) var isLoadingAssignedTickets = false | 144 | private(set) var isLoadingAssignedTickets = false |
| 147 | private(set) var isLoadingRecentBuilds = false | 145 | private(set) var isLoadingRecentBuilds = false |
| 148 | private(set) var failedBuildsError: String? | ||
| 149 | private(set) var assignedTicketsError: String? | 146 | private(set) var assignedTicketsError: String? |
| 150 | private(set) var recentBuildsError: String? | 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 | init(currentUser: User, client: SRHTClient) { | 264 | init(currentUser: User, client: SRHTClient) { |
| 248 | self.currentUser = currentUser | 265 | self.currentUser = currentUser |
| 249 | self.client = client | 266 | self.client = client |
| @@ -252,10 +269,8 @@ final class HomeViewModel { | |||
| 252 | 269 | ||
| 253 | func loadDashboard() async { | 270 | func loadDashboard() async { |
| 254 | isLoadingProjects = true | 271 | isLoadingProjects = true |
| 255 | isLoadingFailedBuilds = true | ||
| 256 | isLoadingAssignedTickets = true | 272 | isLoadingAssignedTickets = true |
| 257 | isLoadingRecentBuilds = true | 273 | isLoadingRecentBuilds = true |
| 258 | failedBuildsError = nil | ||
| 259 | assignedTicketsError = nil | 274 | assignedTicketsError = nil |
| 260 | recentBuildsError = nil | 275 | recentBuildsError = nil |
| 261 | 276 | ||
| @@ -279,16 +294,11 @@ final class HomeViewModel { | |||
| 279 | case .success(let recentJobs): | 294 | case .success(let recentJobs): |
| 280 | let buildItems = Self.buildItems(from: recentJobs) | 295 | let buildItems = Self.buildItems(from: recentJobs) |
| 281 | self.recentBuilds = buildItems | 296 | self.recentBuilds = buildItems |
| 282 | self.failedBuilds = Self.failedBuilds(from: buildItems) | ||
| 283 | self.failedBuildsError = nil | ||
| 284 | self.recentBuildsError = nil | 297 | self.recentBuildsError = nil |
| 285 | case .failure(let error): | 298 | case .failure(let error): |
| 286 | self.recentBuilds = [] | 299 | self.recentBuilds = [] |
| 287 | self.failedBuilds = [] | ||
| 288 | self.failedBuildsError = error.userFacingMessage | ||
| 289 | self.recentBuildsError = error.userFacingMessage | 300 | self.recentBuildsError = error.userFacingMessage |
| 290 | } | 301 | } |
| 291 | isLoadingFailedBuilds = false | ||
| 292 | isLoadingRecentBuilds = false | 302 | isLoadingRecentBuilds = false |
| 293 | 303 | ||
| 294 | let assignedTicketsResult = await assignedTicketsTask | 304 | let assignedTicketsResult = await assignedTicketsTask |
| @@ -306,6 +316,70 @@ final class HomeViewModel { | |||
| 306 | hasUnreadInboxThreads = (await inboxUnreadTask) ?? false | 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 | private func loadProjects() async -> Result<[Project], Error> { | 383 | private func loadProjects() async -> Result<[Project], Error> { |
| 310 | do { | 384 | do { |
| 311 | return .success(try await projectService.fetchProjects()) | 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 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { | 618 | nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { |
| 525 | jobs.map { job in | 619 | jobs.map { job in |
| 526 | let repository = primaryRepositoryReference(in: job.manifest) | 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] { | 629 | nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { |
| 536 | builds.filter { build in | 630 | buildItems(from: jobs).filter { build in |
| 537 | switch build.job.status { | 631 | switch build.job.status { |
| 538 | case .failed, .timeout: | 632 | case .failed, .timeout: |
| 539 | true | 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 | nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { | 640 | nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { |
| 551 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) | 641 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) |
| 552 | let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) | 642 | let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) |
| @@ -598,6 +688,30 @@ final class HomeViewModel { | |||
| 598 | } | 688 | } |
| 599 | return trimmed | 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 | struct HomeJobPayload: Decodable, Sendable { | 716 | struct HomeJobPayload: Decodable, Sendable { |
| 603 | let id: Int | 717 | let id: Int |
Hutch/Views/Pastes/PasteListView.swift +76 −1
| @@ -1,10 +1,12 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct PasteListView: View { | 3 | struct PasteListView: View { |
| 4 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | ||
| 4 | @Environment(AppState.self) private var appState | 5 | @Environment(AppState.self) private var appState |
| 5 | @State private var viewModel: PasteListViewModel? | 6 | @State private var viewModel: PasteListViewModel? |
| 6 | @State private var showCreatePasteSheet = false | 7 | @State private var showCreatePasteSheet = false |
| 7 | @State private var createdPaste: Paste? | 8 | @State private var createdPaste: Paste? |
| 9 | @State private var pasteToDelete: Paste? | ||
| 8 | 10 | ||
| 9 | var body: some View { | 11 | var body: some View { |
| 10 | Group { | 12 | Group { |
| @@ -68,10 +70,32 @@ struct PasteListView: View { | |||
| 68 | @Bindable var vm = viewModel | 70 | @Bindable var vm = viewModel |
| 69 | 71 | ||
| 70 | List { | 72 | List { |
| 71 | ForEach(viewModel.pastes) { paste in | 73 | ForEach(viewModel.filteredPastes) { paste in |
| 72 | NavigationLink(value: paste) { | 74 | NavigationLink(value: paste) { |
| 73 | PasteRowView(paste: paste) | 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 | .task { | 99 | .task { |
| 76 | await viewModel.loadMoreIfNeeded(currentItem: paste) | 100 | await viewModel.loadMoreIfNeeded(currentItem: paste) |
| 77 | } | 101 | } |
| @@ -115,6 +139,24 @@ struct PasteListView: View { | |||
| 115 | await viewModel.loadPastes() | 139 | await viewModel.loadPastes() |
| 116 | } | 140 | } |
| 117 | .srhtErrorBanner(error: $vm.error) | 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 | .refreshable { | 160 | .refreshable { |
| 119 | await viewModel.loadPastes() | 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 | private struct PasteRowView: View { | 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 | func upsertPaste(_ paste: Paste) { | 151 | func upsertPaste(_ paste: Paste) { |
| 111 | if let index = pastes.firstIndex(where: { $0.id == paste.id }) { | 152 | if let index = pastes.firstIndex(where: { $0.id == paste.id }) { |
| 112 | pastes[index] = paste | 153 | pastes[index] = paste |
Hutch/Views/Tickets/TicketListView.swift +208
| @@ -6,10 +6,12 @@ struct TicketListView: View { | |||
| 6 | let trackerId: Int | 6 | let trackerId: Int |
| 7 | let trackerRid: String | 7 | let trackerRid: String |
| 8 | 8 | ||
| 9 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | ||
| 9 | @Environment(AppState.self) private var appState | 10 | @Environment(AppState.self) private var appState |
| 10 | @State private var viewModel: TicketListViewModel? | 11 | @State private var viewModel: TicketListViewModel? |
| 11 | @State private var showCreateTicketSheet = false | 12 | @State private var showCreateTicketSheet = false |
| 12 | @State private var createdTicket: TicketSummary? | 13 | @State private var createdTicket: TicketSummary? |
| 14 | @State private var labelEditorTicket: LabelEditorTicket? | ||
| 13 | 15 | ||
| 14 | var body: some View { | 16 | var body: some View { |
| 15 | Group { | 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 | .navigationDestination(isPresented: Binding( | 59 | .navigationDestination(isPresented: Binding( |
| 49 | get: { createdTicket != nil }, | 60 | get: { createdTicket != nil }, |
| 50 | set: { isPresented in | 61 | set: { isPresented in |
| @@ -63,6 +74,7 @@ struct TicketListView: View { | |||
| 63 | ownerUsername: ownerUsername, | 74 | ownerUsername: ownerUsername, |
| 64 | trackerName: trackerName, | 75 | trackerName: trackerName, |
| 65 | trackerId: trackerId, | 76 | trackerId: trackerId, |
| 77 | trackerRid: trackerRid, | ||
| 66 | client: appState.client | 78 | client: appState.client |
| 67 | ) | 79 | ) |
| 68 | viewModel = vm | 80 | viewModel = vm |
| @@ -93,6 +105,16 @@ struct TicketListView: View { | |||
| 93 | NavigationLink(value: ticket) { | 105 | NavigationLink(value: ticket) { |
| 94 | TicketRowView(ticket: ticket) | 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 | .task { | 118 | .task { |
| 97 | await viewModel.loadMoreIfNeeded(currentItem: ticket) | 119 | await viewModel.loadMoreIfNeeded(currentItem: ticket) |
| 98 | } | 120 | } |
| @@ -144,6 +166,106 @@ struct TicketListView: View { | |||
| 144 | TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id) | 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 | private struct CreateTicketSheet: View { | 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 | // MARK: - Ticket Row | 402 | // MARK: - Ticket Row |
| 195 | 403 | ||
| 196 | private struct TicketRowView: View { | 404 | private struct TicketRowView: View { |
Hutch/Views/Tickets/TicketListViewModel.swift +305
| @@ -19,6 +19,48 @@ private struct TicketsPage: Decodable, Sendable { | |||
| 19 | let cursor: String? | 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 | // MARK: - Filter | 64 | // MARK: - Filter |
| 23 | 65 | ||
| 24 | enum TicketFilter: String, CaseIterable, Sendable { | 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 | // MARK: - Private | 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 | private func fetchPage(cursor: String?) async throws -> TicketsPage { | 532 | private func fetchPage(cursor: String?) async throws -> TicketsPage { |
| 266 | var variables: [String: any Sendable] = [ | 533 | var variables: [String: any Sendable] = [ |
| 267 | "owner": ownerUsername, | 534 | "owner": ownerUsername, |
| @@ -282,4 +549,42 @@ final class TicketListViewModel { | |||
| 282 | private struct SubmitTicketResponse: Decodable, Sendable { | 549 | private struct SubmitTicketResponse: Decodable, Sendable { |
| 283 | let submitTicket: TicketSummary | 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 | } | ||