diff options
| author | Christian Cleberg <[email protected]> | 2026-03-22 20:42:53 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-22 20:42:53 -0500 |
| commit | 82e54ad018ae5fa5cdf90c0d763e09e99bc0e933 (patch) | |
| tree | cafdbc8f4116af5cc6e0ca706e05ced599e26570 /Hutch/Views/Home | |
| parent | 1f8b7a23529462c3c332753d33810b5c9f692bbc (diff) | |
| download | hutch-82e54ad018ae5fa5cdf90c0d763e09e99bc0e933.tar.gz hutch-82e54ad018ae5fa5cdf90c0d763e09e99bc0e933.tar.bz2 hutch-82e54ad018ae5fa5cdf90c0d763e09e99bc0e933.zip | |
feat: swipe actions on tickets, pastes, builds, and home dashboard
Diffstat (limited to 'Hutch/Views/Home')
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 87 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 150 |
2 files changed, 219 insertions, 18 deletions
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift index 55ec41e..d3d370a 100644 --- a/Hutch/Views/Home/HomeView.swift +++ b/Hutch/Views/Home/HomeView.swift @@ -1,6 +1,7 @@ import SwiftUI struct HomeView: View { + @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true @Environment(AppState.self) private var appState @State private var viewModel: HomeViewModel? private let previewLimit = 4 @@ -109,6 +110,23 @@ struct HomeView: View { } label: { HomeAssignedTicketRow(ticket: ticket) } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled { + ticketLeadingSwipeAction(ticket, viewModel: viewModel) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if swipeActionsEnabled { + Button { + Task { + await viewModel.unassignFromMe(ticket) + } + } label: { + Label("Unassign Me", systemImage: "person.badge.minus") + } + .tint(.orange) + } + } } } } header: { @@ -142,6 +160,19 @@ struct HomeView: View { } label: { HomeBuildRow(build: build) } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled, build.job.status.isCancellable { + Button { + Task { + await viewModel.cancelBuild(build) + } + } + label: { + Label("Cancel", systemImage: "xmark.circle") + } + .tint(.red) + } + } } } } header: { @@ -151,6 +182,32 @@ struct HomeView: View { } } + @ViewBuilder + private func ticketLeadingSwipeAction( + _ ticket: HomeAssignedTicket, + viewModel: HomeViewModel + ) -> some View { + if ticket.ticket.status.isOpen { + Button { + Task { + await viewModel.resolveTicket(ticket) + } + } label: { + Label("Resolve", systemImage: "checkmark.circle") + } + .tint(.green) + } else { + Button { + Task { + await viewModel.reopenTicket(ticket) + } + } label: { + Label("Reopen", systemImage: "arrow.uturn.backward") + } + .tint(.blue) + } + } + } private struct HomeInboxToolbarIcon: View { @@ -367,6 +424,7 @@ private struct HomeSectionActionHeader: View { private struct HomeAssignedTicketsListView: View { let viewModel: HomeViewModel + @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true var body: some View { List { @@ -382,6 +440,35 @@ private struct HomeAssignedTicketsListView: View { } label: { HomeAssignedTicketRow(ticket: ticket) } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled { + if ticket.ticket.status.isOpen { + Button { + Task { await viewModel.resolveTicket(ticket) } + } label: { + Label("Resolve", systemImage: "checkmark.circle") + } + .tint(.green) + } else { + Button { + Task { await viewModel.reopenTicket(ticket) } + } label: { + Label("Reopen", systemImage: "arrow.uturn.backward") + } + .tint(.blue) + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if swipeActionsEnabled { + Button { + Task { await viewModel.unassignFromMe(ticket) } + } label: { + Label("Unassign Me", systemImage: "person.badge.minus") + } + .tint(.orange) + } + } } if !viewModel.isLoadingAssignedTickets && viewModel.assignedTickets.isEmpty { diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 4f3a514..5897aca 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -137,15 +137,12 @@ struct HomeBuildItem: Identifiable, Hashable, Sendable { @MainActor final class HomeViewModel { private(set) var projects: [Project] = [] - private(set) var failedBuilds: [HomeBuildItem] = [] - private(set) var assignedTickets: [HomeAssignedTicket] = [] - private(set) var recentBuilds: [HomeBuildItem] = [] + var assignedTickets: [HomeAssignedTicket] = [] + var recentBuilds: [HomeBuildItem] = [] private(set) var hasUnreadInboxThreads = false private(set) var isLoadingProjects = false - private(set) var isLoadingFailedBuilds = false private(set) var isLoadingAssignedTickets = false private(set) var isLoadingRecentBuilds = false - private(set) var failedBuildsError: String? private(set) var assignedTicketsError: String? private(set) var recentBuildsError: String? @@ -244,6 +241,26 @@ final class HomeViewModel { } """ + private static let updateTicketStatusMutation = """ + mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) { + updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) { + eventType: __typename + } + } + """ + + private static let unassignUserMutation = """ + mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { + unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } + } + """ + + private static let cancelBuildMutation = """ + mutation cancel($id: Int!) { + cancel(jobId: $id) { id } + } + """ + init(currentUser: User, client: SRHTClient) { self.currentUser = currentUser self.client = client @@ -252,10 +269,8 @@ final class HomeViewModel { func loadDashboard() async { isLoadingProjects = true - isLoadingFailedBuilds = true isLoadingAssignedTickets = true isLoadingRecentBuilds = true - failedBuildsError = nil assignedTicketsError = nil recentBuildsError = nil @@ -279,16 +294,11 @@ final class HomeViewModel { case .success(let recentJobs): let buildItems = Self.buildItems(from: recentJobs) self.recentBuilds = buildItems - self.failedBuilds = Self.failedBuilds(from: buildItems) - self.failedBuildsError = nil self.recentBuildsError = nil case .failure(let error): self.recentBuilds = [] - self.failedBuilds = [] - self.failedBuildsError = error.userFacingMessage self.recentBuildsError = error.userFacingMessage } - isLoadingFailedBuilds = false isLoadingRecentBuilds = false let assignedTicketsResult = await assignedTicketsTask @@ -306,6 +316,70 @@ final class HomeViewModel { hasUnreadInboxThreads = (await inboxUnreadTask) ?? false } + func resolveTicket(_ ticket: HomeAssignedTicket) async { + let input: [String: any Sendable] = [ + "status": TicketStatus.resolved.rawValue, + "resolution": TicketResolution.fixed.rawValue + ] + await performTicketStatusUpdate(ticket: ticket, input: input) + } + + func reopenTicket(_ ticket: HomeAssignedTicket) async { + let input: [String: any Sendable] = [ + "status": TicketStatus.reported.rawValue + ] + await performTicketStatusUpdate(ticket: ticket, input: input) + } + + func unassignFromMe(_ ticket: HomeAssignedTicket) async { + do { + _ = try await client.execute( + service: .todo, + query: Self.unassignUserMutation, + variables: [ + "trackerId": ticket.trackerId, + "ticketId": ticket.ticket.id, + "userId": currentUser.id + ], + responseType: UnassignResponse.self + ) + assignedTickets.removeAll { $0.id == ticket.id } + } catch { + } + } + + func cancelBuild(_ build: HomeBuildItem) async { + guard build.job.status.isCancellable else { return } + + do { + _ = try await client.execute( + service: .builds, + query: Self.cancelBuildMutation, + variables: ["id": build.job.id], + responseType: CancelBuildResponse.self + ) + if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) { + let updatedJob = JobSummary( + id: build.job.id, + created: build.job.created, + updated: build.job.updated, + status: .cancelled, + note: build.job.note, + tags: build.job.tags, + visibility: build.job.visibility, + image: build.job.image, + tasks: build.job.tasks + ) + recentBuilds[index] = HomeBuildItem( + job: updatedJob, + repositoryName: build.repositoryName, + repositoryOwner: build.repositoryOwner + ) + } + } catch { + } + } + private func loadProjects() async -> Result<[Project], Error> { do { return .success(try await projectService.fetchProjects()) @@ -521,6 +595,26 @@ final class HomeViewModel { } } + private func performTicketStatusUpdate( + ticket: HomeAssignedTicket, + input: [String: any Sendable] + ) async { + do { + _ = try await client.execute( + service: .todo, + query: Self.updateTicketStatusMutation, + variables: [ + "trackerId": ticket.trackerId, + "ticketId": ticket.ticket.id, + "input": input + ], + responseType: StatusEventResponse.self + ) + assignedTickets.removeAll { $0.id == ticket.id } + } catch { + } + } + nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { jobs.map { job in let repository = primaryRepositoryReference(in: job.manifest) @@ -532,8 +626,8 @@ final class HomeViewModel { } } - nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] { - builds.filter { build in + nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { + buildItems(from: jobs).filter { build in switch build.job.status { case .failed, .timeout: true @@ -543,10 +637,6 @@ final class HomeViewModel { } } - nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] { - failedBuilds(from: buildItems(from: jobs)) - } - nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool { let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) let currentCanonical = normalizedCanonicalName(currentUser.canonicalName) @@ -598,6 +688,30 @@ final class HomeViewModel { } return trimmed } + + private struct StatusEventResponse: Decodable, Sendable { + struct EventRef: Decodable, Sendable { + let eventType: String + } + + let updateTicketStatus: EventRef + } + + private struct UnassignResponse: Decodable, Sendable { + struct EventRef: Decodable, Sendable { + let id: Int + } + + let unassignUser: EventRef + } + + private struct CancelBuildResponse: Decodable, Sendable { + struct CancelResult: Decodable, Sendable { + let id: Int + } + + let cancel: CancelResult + } } struct HomeJobPayload: Decodable, Sendable { let id: Int |
