diff options
| -rw-r--r-- | Hutch/Views/Builds/BuildListView.swift | 1 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListViewModel.swift | 37 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeView.swift | 87 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 150 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListView.swift | 77 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListViewModel.swift | 41 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 208 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 305 | ||||
| -rw-r--r-- | HutchTests/TicketListViewModelTests.swift | 116 |
9 files changed, 1003 insertions, 19 deletions
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift index e248ca8..6bfbd6b 100644 --- a/Hutch/Views/Builds/BuildListView.swift +++ b/Hutch/Views/Builds/BuildListView.swift @@ -1,6 +1,7 @@ import SwiftUI struct BuildListView: View { + @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true @Environment(AppState.self) private var appState @State private var viewModel: BuildListViewModel? @State private var showSubmitSheet = false diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift index 7b0b0b9..0757ad8 100644 --- a/Hutch/Views/Builds/BuildListViewModel.swift +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -194,6 +194,35 @@ final class BuildListViewModel { } } + func cancelJob(_ job: JobSummary) async { + guard job.status.isCancellable else { return } + + do { + _ = try await client.execute( + service: .builds, + query: Self.cancelMutation, + variables: ["id": job.id], + responseType: CancelResponse.self + ) + if let index = jobs.firstIndex(where: { $0.id == job.id }) { + let updated = JobSummary( + id: job.id, + created: job.created, + updated: job.updated, + status: .cancelled, + note: job.note, + tags: job.tags, + visibility: job.visibility, + image: job.image, + tasks: job.tasks + ) + jobs[index] = updated + } + } catch { + self.error = error.userFacingMessage + } + } + // MARK: - Private private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { @@ -235,4 +264,12 @@ final class BuildListViewModel { hasMore = page.cursor != nil } } + + private struct CancelResponse: Decodable, Sendable { + struct CancelResult: Decodable, Sendable { + let id: Int + } + + let cancel: CancelResult + } } 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 diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift index 84f2926..22f6ff9 100644 --- a/Hutch/Views/Pastes/PasteListView.swift +++ b/Hutch/Views/Pastes/PasteListView.swift @@ -1,10 +1,12 @@ import SwiftUI struct PasteListView: View { + @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true @Environment(AppState.self) private var appState @State private var viewModel: PasteListViewModel? @State private var showCreatePasteSheet = false @State private var createdPaste: Paste? + @State private var pasteToDelete: Paste? var body: some View { Group { @@ -68,10 +70,32 @@ struct PasteListView: View { @Bindable var vm = viewModel List { - ForEach(viewModel.pastes) { paste in + ForEach(viewModel.filteredPastes) { paste in NavigationLink(value: paste) { PasteRowView(paste: paste) } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled { + Button { + Task { await viewModel.cycleVisibility(for: paste) } + } label: { + Label( + nextVisibilityLabel(for: paste.visibility), + systemImage: nextVisibilityIcon(for: paste.visibility) + ) + } + .tint(nextVisibilityColor(for: paste.visibility)) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if swipeActionsEnabled { + Button(role: .destructive) { + pasteToDelete = paste + } label: { + Label("Delete", systemImage: "trash") + } + } + } .task { await viewModel.loadMoreIfNeeded(currentItem: paste) } @@ -115,6 +139,24 @@ struct PasteListView: View { await viewModel.loadPastes() } .srhtErrorBanner(error: $vm.error) + .alert("Delete Paste?", isPresented: Binding( + get: { pasteToDelete != nil }, + set: { if !$0 { pasteToDelete = nil } } + )) { + Button("Cancel", role: .cancel) { + pasteToDelete = nil + } + Button("Delete", role: .destructive) { + if let paste = pasteToDelete { + pasteToDelete = nil + Task { + await viewModel.deletePaste(paste) + } + } + } + } message: { + Text("This paste will be permanently deleted from SourceHut.") + } .refreshable { await viewModel.loadPastes() } @@ -130,6 +172,39 @@ struct PasteListView: View { ) } } + + private func nextVisibilityLabel(for visibility: Visibility) -> String { + switch visibility { + case .public: + return "Make Unlisted" + case .unlisted: + return "Make Private" + case .private: + return "Make Public" + } + } + + private func nextVisibilityIcon(for visibility: Visibility) -> String { + switch visibility { + case .public: + return "eye.slash" + case .unlisted: + return "lock" + case .private: + return "globe" + } + } + + private func nextVisibilityColor(for visibility: Visibility) -> Color { + switch visibility { + case .public: + return .orange + case .unlisted: + return .red + case .private: + return .green + } + } } private struct PasteRowView: View { diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift index d41cf96..b521e18 100644 --- a/Hutch/Views/Pastes/PasteListViewModel.swift +++ b/Hutch/Views/Pastes/PasteListViewModel.swift @@ -107,6 +107,47 @@ final class PasteListViewModel { } } + func deletePaste(_ paste: Paste) async { + do { + _ = try await service.deletePaste(id: paste.id) + removePaste(id: paste.id) + } catch { + self.error = error.userFacingMessage + } + } + + func cycleVisibility(for paste: Paste) async { + let next: Visibility + switch paste.visibility { + case .public: + next = .unlisted + case .unlisted: + next = .private + case .private: + next = .public + } + + let original = pastes + if let index = pastes.firstIndex(where: { $0.id == paste.id }) { + pastes[index] = Paste( + id: paste.id, + created: paste.created, + visibility: next, + files: paste.files, + user: paste.user + ) + } + + do { + if let updated = try await service.updateVisibility(id: paste.id, visibility: next) { + upsertPaste(updated) + } + } catch { + pastes = original + self.error = error.userFacingMessage + } + } + func upsertPaste(_ paste: Paste) { if let index = pastes.firstIndex(where: { $0.id == paste.id }) { pastes[index] = paste diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index 93a7032..69ab419 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -6,10 +6,12 @@ struct TicketListView: View { let trackerId: Int let trackerRid: String + @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true @Environment(AppState.self) private var appState @State private var viewModel: TicketListViewModel? @State private var showCreateTicketSheet = false @State private var createdTicket: TicketSummary? + @State private var labelEditorTicket: LabelEditorTicket? var body: some View { Group { @@ -45,6 +47,15 @@ struct TicketListView: View { } } } + .sheet(item: $labelEditorTicket) { item in + if let viewModel { + TicketLabelsSheet( + ticketId: item.id, + viewModel: viewModel + ) + .presentationDetents([.medium]) + } + } .navigationDestination(isPresented: Binding( get: { createdTicket != nil }, set: { isPresented in @@ -63,6 +74,7 @@ struct TicketListView: View { ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, + trackerRid: trackerRid, client: appState.client ) viewModel = vm @@ -93,6 +105,16 @@ struct TicketListView: View { NavigationLink(value: ticket) { TicketRowView(ticket: ticket) } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled { + ticketLeadingSwipeAction(ticket, viewModel: viewModel) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if swipeActionsEnabled { + ticketTrailingSwipeActions(ticket, viewModel: viewModel) + } + } .task { await viewModel.loadMoreIfNeeded(currentItem: ticket) } @@ -144,6 +166,106 @@ struct TicketListView: View { TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id) } } + + @ViewBuilder + private func ticketLeadingSwipeAction( + _ ticket: TicketSummary, + viewModel: TicketListViewModel + ) -> some View { + if ticket.status.isOpen { + Button { + Task { + await resolveTicket(ticket, viewModel: viewModel) + } + } label: { + Label("Resolve", systemImage: "checkmark.circle") + } + .tint(.green) + } else { + Button { + Task { + await reopenTicket(ticket, viewModel: viewModel) + } + } label: { + Label("Reopen", systemImage: "arrow.uturn.backward") + } + .tint(.blue) + } + } + + private func resolveTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async { + await viewModel.resolveTicket(ticket) + } + + private func reopenTicket(_ ticket: TicketSummary, viewModel: TicketListViewModel) async { + await viewModel.reopenTicket(ticket) + } + + @ViewBuilder + private func ticketTrailingSwipeActions( + _ ticket: TicketSummary, + viewModel: TicketListViewModel + ) -> some View { + Button { + labelEditorTicket = LabelEditorTicket(id: ticket.id) + Task { await viewModel.loadTrackerLabels() } + } label: { + Label("Edit Labels", systemImage: "tag") + } + .tint(.purple) + + if let currentUser = appState.currentUser { + let isAssigned = ticket.assignees.contains { assignee in + matchesAssignee(assignee, user: currentUser) + } + + if isAssigned { + Button { + Task { + await viewModel.unassignFromMe(ticket: ticket, user: currentUser) + } + } label: { + Label("Unassign Me", systemImage: "person.badge.minus") + } + .tint(.orange) + } else { + Button { + Task { + await viewModel.assignToMe(ticket: ticket, user: currentUser) + } + } label: { + Label("Assign Me", systemImage: "person.badge.plus") + } + .tint(.cyan) + } + } + } + + private func matchesAssignee(_ entity: Entity, user: User) -> Bool { + let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) + let userCanonical = normalizedCanonicalName(user.canonicalName) + if assigneeCanonical == userCanonical { + return true + } + return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username) + } + + private func normalizedCanonicalName(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") { + return trimmed + } + return "~\(trimmed)" + } + + private func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } +} + +private struct LabelEditorTicket: Identifiable { + let id: Int } private struct CreateTicketSheet: View { @@ -191,6 +313,92 @@ private struct CreateTicketSheet: View { } } +private struct TicketLabelsSheet: View { + let ticketId: Int + let viewModel: TicketListViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if let ticket = viewModel.ticket(withId: ticketId) { + if viewModel.trackerLabels.isEmpty { + if viewModel.isPerformingAction { + ProgressView() + } else { + ContentUnavailableView( + "No Labels", + systemImage: "tag", + description: Text("This tracker has no labels defined.") + ) + } + } else { + List { + ForEach(viewModel.trackerLabels) { label in + TicketListLabelToggleRow( + label: label, + isApplied: ticket.labels.contains(where: { $0.id == label.id }), + isLoading: viewModel.isPerformingAction + ) { shouldApply in + Task { + if shouldApply { + await viewModel.labelTicket(ticket, label: label) + } else { + await viewModel.unlabelTicket(ticket, label: label) + } + } + } + } + } + } + } else { + ContentUnavailableView( + "Ticket Unavailable", + systemImage: "ticket", + description: Text("This ticket is no longer in the current list.") + ) + } + } + .navigationTitle("Labels") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .task { + if viewModel.trackerLabels.isEmpty { + await viewModel.loadTrackerLabels() + } + } + } + } +} + +private struct TicketListLabelToggleRow: View { + let label: TicketLabel + let isApplied: Bool + let isLoading: Bool + let onToggle: (Bool) -> Void + + var body: some View { + Button { + onToggle(!isApplied) + } label: { + HStack { + LabelPill(label: label) + Spacer() + if isApplied { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + } + .disabled(isLoading) + } +} + // MARK: - Ticket Row private struct TicketRowView: View { diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift index 303fd66..4a9f8df 100644 --- a/Hutch/Views/Tickets/TicketListViewModel.swift +++ b/Hutch/Views/Tickets/TicketListViewModel.swift @@ -19,6 +19,48 @@ private struct TicketsPage: Decodable, Sendable { let cursor: String? } +private struct AssignmentMutationResponse: Decodable, Sendable { + struct EventRef: Decodable, Sendable { + let id: Int + } + + let assignUser: EventRef? + let unassignUser: EventRef? +} + +private struct LabelMutationResponse: Decodable, Sendable { + struct EventRef: Decodable, Sendable { + let id: Int + } + + let labelTicket: EventRef? + let unlabelTicket: EventRef? +} + +private struct TrackerLabelsResponse: Decodable, Sendable { + let user: UserTrackerLabelsWrapper +} + +private struct UserTrackerLabelsWrapper: Decodable, Sendable { + let tracker: TrackerLabelsWrapper +} + +private struct TrackerLabelsWrapper: Decodable, Sendable { + let labels: LabelsPage +} + +private struct LabelsPage: Decodable, Sendable { + let results: [TicketLabel] +} + +private struct UpdateStatusResponse: Decodable, Sendable { + let updateTicketStatus: MutationEventRef +} + +private struct MutationEventRef: Decodable, Sendable { + let eventType: String +} + // MARK: - Filter enum TicketFilter: String, CaseIterable, Sendable { @@ -260,8 +302,233 @@ final class TicketListViewModel { } } + func resolveTicket(_ ticket: TicketSummary) async { + let input: [String: any Sendable] = [ + "status": TicketStatus.resolved.rawValue, + "resolution": TicketResolution.fixed.rawValue + ] + await performStatusUpdate(ticket: ticket, input: input) + } + + func reopenTicket(_ ticket: TicketSummary) async { + let input: [String: any Sendable] = [ + "status": TicketStatus.reported.rawValue + ] + await performStatusUpdate(ticket: ticket, input: input) + } + + func assignToMe(ticket: TicketSummary, user: User) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + let original = tickets + if let index = tickets.firstIndex(where: { $0.id == ticket.id }) { + let entity = Entity(canonicalName: user.canonicalName) + let updated = TicketSummary( + id: ticket.id, + title: ticket.title, + status: ticket.status, + resolution: ticket.resolution, + created: ticket.created, + submitter: ticket.submitter, + labels: ticket.labels, + assignees: ticket.assignees + [entity] + ) + tickets[index] = updated + } + + do { + _ = try await client.execute( + service: .todo, + query: Self.assignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticket.id, + "userId": user.id + ], + responseType: AssignmentMutationResponse.self + ) + } catch { + tickets = original + self.error = error.userFacingMessage + } + + isPerformingAction = false + } + + func unassignFromMe(ticket: TicketSummary, user: User) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + let original = tickets + if let index = tickets.firstIndex(where: { $0.id == ticket.id }) { + let filtered = ticket.assignees.filter { assignee in + !Self.matchesAssignee(assignee, user: user) + } + let updated = TicketSummary( + id: ticket.id, + title: ticket.title, + status: ticket.status, + resolution: ticket.resolution, + created: ticket.created, + submitter: ticket.submitter, + labels: ticket.labels, + assignees: filtered + ) + tickets[index] = updated + } + + do { + _ = try await client.execute( + service: .todo, + query: Self.unassignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticket.id, + "userId": user.id + ], + responseType: AssignmentMutationResponse.self + ) + } catch { + tickets = original + self.error = error.userFacingMessage + } + + isPerformingAction = false + } + + func loadTrackerLabels() async { + do { + let result = try await client.execute( + service: .todo, + query: Self.trackerLabelsQuery, + variables: [ + "owner": ownerUsername, + "tracker": trackerName + ], + responseType: TrackerLabelsResponse.self + ) + trackerLabels = result.user.tracker.labels.results + } catch { + self.error = error.userFacingMessage + } + } + + func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + let original = tickets + if let index = tickets.firstIndex(where: { $0.id == ticket.id }) { + let updated = TicketSummary( + id: ticket.id, + title: ticket.title, + status: ticket.status, + resolution: ticket.resolution, + created: ticket.created, + submitter: ticket.submitter, + labels: ticket.labels + [label], + assignees: ticket.assignees + ) + tickets[index] = updated + } + + do { + _ = try await client.execute( + service: .todo, + query: Self.labelTicketMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticket.id, + "labelId": label.id + ], + responseType: LabelMutationResponse.self + ) + } catch { + tickets = original + self.error = error.userFacingMessage + } + + isPerformingAction = false + } + + func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + let original = tickets + if let index = tickets.firstIndex(where: { $0.id == ticket.id }) { + let filtered = ticket.labels.filter { $0.id != label.id } + let updated = TicketSummary( + id: ticket.id, + title: ticket.title, + status: ticket.status, + resolution: ticket.resolution, + created: ticket.created, + submitter: ticket.submitter, + labels: filtered, + assignees: ticket.assignees + ) + tickets[index] = updated + } + + do { + _ = try await client.execute( + service: .todo, + query: Self.unlabelTicketMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticket.id, + "labelId": label.id + ], + responseType: LabelMutationResponse.self + ) + } catch { + tickets = original + self.error = error.userFacingMessage + } + + isPerformingAction = false + } + + func ticket(withId ticketId: Int) -> TicketSummary? { + tickets.first(where: { $0.id == ticketId }) + } + // MARK: - Private + private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + let variables: [String: any Sendable] = [ + "trackerId": trackerId, + "ticketId": ticket.id, + "input": input + ] + let result = try await client.execute( + service: .todo, + query: Self.updateStatusMutation, + variables: variables, + responseType: UpdateStatusResponse.self + ) + _ = result.updateTicketStatus + if let index = tickets.firstIndex(where: { $0.id == ticket.id }) { + tickets[index] = updatedTicket(from: ticket, input: input) + } + } catch { + self.error = error.userFacingMessage + } + + isPerformingAction = false + } + private func fetchPage(cursor: String?) async throws -> TicketsPage { var variables: [String: any Sendable] = [ "owner": ownerUsername, @@ -282,4 +549,42 @@ final class TicketListViewModel { private struct SubmitTicketResponse: Decodable, Sendable { let submitTicket: TicketSummary } + + private static func matchesAssignee(_ entity: Entity, user: User) -> Bool { + let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) + let userCanonical = normalizedCanonicalName(user.canonicalName) + if assigneeCanonical == userCanonical { + return true + } + return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username) + } + + private static func normalizedCanonicalName(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("~") { + return trimmed + } + return "~\(trimmed)" + } + + private static func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } + + private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary { + let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status + let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:)) + + return TicketSummary( + id: ticket.id, + title: ticket.title, + status: updatedStatus, + resolution: updatedStatus == .resolved ? updatedResolution : nil, + created: ticket.created, + submitter: ticket.submitter, + labels: ticket.labels, + assignees: ticket.assignees + ) + } } diff --git a/HutchTests/TicketListViewModelTests.swift b/HutchTests/TicketListViewModelTests.swift new file mode 100644 index 0000000..23be24c --- /dev/null +++ b/HutchTests/TicketListViewModelTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing +@testable import Hutch + +struct TicketListViewModelTests { + + @Test + func filteredTicketsReturnsStatusFilteredTicketsWhenSearchTextIsEmpty() { + let tickets = [ + makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: []), + makeTicket(id: 2, title: "Already fixed", status: .resolved, submitter: "~owner", labels: []) + ] + + let filtered = filterTickets(tickets, filter: .open, query: "") + + #expect(filtered.map(\.id) == [1]) + } + + @Test + func filteredTicketsMatchesTitleAndTicketId() { + let tickets = [ + makeTicket(id: 42, title: "Crash on launch", status: .reported, submitter: "~owner", labels: []), + makeTicket(id: 99, title: "Settings polish", status: .reported, submitter: "~owner", labels: []) + ] + + let titleMatches = filterTickets(tickets, filter: .all, query: "settings") + let idMatches = filterTickets(tickets, filter: .all, query: "42") + + #expect(titleMatches.map(\.id) == [99]) + #expect(idMatches.map(\.id) == [42]) + } + + @Test + func filteredTicketsMatchesSubmitterAndLabels() { + let tickets = [ + makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: [makeLabel(id: 1, name: "bug")]), + makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")]) + ] + + let submitterMatches = filterTickets(tickets, filter: .all, query: "~triage") + let labelMatches = filterTickets(tickets, filter: .all, query: "bug") + + #expect(submitterMatches.map(\.id) == [2]) + #expect(labelMatches.map(\.id) == [1]) + } + + @Test + @MainActor + func resolveTicketInputHasCorrectStatusAndDefaultResolution() { + let input: [String: any Sendable] = [ + "status": TicketStatus.resolved.rawValue, + "resolution": TicketResolution.fixed.rawValue + ] + #expect(input["status"] as? String == "resolved") + #expect(input["resolution"] as? String == "fixed") + } + + @Test + @MainActor + func reopenTicketInputOmitsResolution() { + let input: [String: any Sendable] = [ + "status": TicketStatus.reported.rawValue + ] + #expect(input["status"] as? String == "reported") + #expect(input["resolution"] == nil) + } + + private func filterTickets(_ tickets: [TicketSummary], filter: TicketFilter, query: String) -> [TicketSummary] { + let statusFiltered: [TicketSummary] + switch filter { + case .open: + statusFiltered = tickets.filter { $0.status.isOpen } + case .resolved: + statusFiltered = tickets.filter { !$0.status.isOpen } + case .all: + statusFiltered = tickets + } + + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return statusFiltered } + return statusFiltered.filter { + String($0.id).contains(q) || + $0.title.lowercased().contains(q) || + $0.submitter.canonicalName.lowercased().contains(q) || + $0.labels.contains { $0.name.lowercased().contains(q) } + } + } + + private func makeTicket( + id: Int, + title: String, + status: TicketStatus, + submitter: String, + labels: [TicketLabel] + ) -> TicketSummary { + TicketSummary( + id: id, + title: title, + status: status, + resolution: status == .resolved ? .fixed : nil, + created: Date(), + submitter: Entity(canonicalName: submitter), + labels: labels, + assignees: [] + ) + } + + private func makeLabel(id: Int, name: String) -> TicketLabel { + TicketLabel( + id: id, + name: name, + backgroundColor: "#000000", + foregroundColor: "#ffffff" + ) + } +} |
