diff options
Diffstat (limited to 'Hutch/Views/Tickets')
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 133 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailViewModel.swift | 173 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 15 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 88 |
4 files changed, 409 insertions, 0 deletions
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index 92263f6..b114fa6 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -11,12 +11,15 @@ struct TicketDetailView: View { @Environment(AppState.self) private var appState @Environment(\.colorScheme) private var colorScheme @Environment(\.openURL) private var openURL + @Environment(\.dismiss) private var dismiss @State private var viewModel: TicketDetailViewModel? // Sheet state @State private var showResolveSheet = false @State private var showAssignSheet = false @State private var showLabelsSheet = false + @State private var showEditSheet = false + @State private var showDeleteConfirmation = false @State private var isOpeningTracker = false // Comment composer mode @@ -100,9 +103,30 @@ struct TicketDetailView: View { SwiftUI.Label("Copy Tracker RID", systemImage: "number") } + Divider() + + Button { + Task { await viewModel.toggleSubscription() } + } label: { + if viewModel.isSubscribed { + SwiftUI.Label("Unsubscribe", systemImage: "bell.slash") + } else { + SwiftUI.Label("Subscribe", systemImage: "bell") + } + } + .disabled(viewModel.isPerformingAction) + if isOwnedByCurrentUser { Divider() + if viewModel.ticket != nil { + Button { + showEditSheet = true + } label: { + SwiftUI.Label("Edit Ticket", systemImage: "square.and.pencil") + } + } + if let ticket = viewModel.ticket { if ticket.status == .resolved { Button { @@ -133,6 +157,14 @@ struct TicketDetailView: View { } label: { SwiftUI.Label("Manage Labels", systemImage: "tag") } + + Divider() + + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + SwiftUI.Label("Delete Ticket", systemImage: "trash") + } } } label: { Image(systemName: "ellipsis.circle") @@ -150,6 +182,32 @@ struct TicketDetailView: View { LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet) .presentationDetents([.medium]) } + .sheet(isPresented: $showEditSheet) { + if let ticket = viewModel.ticket { + EditTicketSheet( + viewModel: viewModel, + isPresented: $showEditSheet, + initialSubject: ticket.title, + initialBody: ticket.description ?? "" + ) + } + } + .confirmationDialog( + "Delete Ticket #\(ticketId)?", + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete Ticket", role: .destructive) { + Task { + if await viewModel.deleteTicket() { + dismiss() + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This permanently deletes the ticket and its comments. This cannot be undone.") + } } // MARK: - Detail Content @@ -724,6 +782,81 @@ private struct EventRow: View { // MARK: - Resolve Sheet +private struct EditTicketSheet: View { + let viewModel: TicketDetailViewModel + @Binding var isPresented: Bool + let initialSubject: String + let initialBody: String + + @State private var subject: String + @State private var ticketBody: String + + init( + viewModel: TicketDetailViewModel, + isPresented: Binding<Bool>, + initialSubject: String, + initialBody: String + ) { + self.viewModel = viewModel + _isPresented = isPresented + self.initialSubject = initialSubject + self.initialBody = initialBody + _subject = State(initialValue: initialSubject) + _ticketBody = State(initialValue: initialBody) + } + + private var trimmedSubject: String { + subject.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var hasChanges: Bool { + trimmedSubject != initialSubject + || ticketBody.trimmingCharacters(in: .whitespacesAndNewlines) != initialBody + } + + var body: some View { + NavigationStack { + Form { + Section("Subject") { + TextField("Subject", text: $subject, axis: .vertical) + .themedRow() + } + + Section("Description") { + TextField("Description", text: $ticketBody, axis: .vertical) + .lineLimit(5...15) + .themedRow() + } + } + .themedList() + .navigationTitle("Edit Ticket") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isPresented = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { + if await viewModel.updateTicket(subject: subject, body: ticketBody) { + isPresented = false + } + } + } + .disabled(viewModel.isPerformingAction || trimmedSubject.isEmpty || !hasChanges) + } + } + .overlay { + if viewModel.isPerformingAction { + ProgressView() + } + } + } + } +} + +// MARK: - Resolve Sheet + private struct ResolveSheet: View { let viewModel: TicketDetailViewModel @Binding var isPresented: Bool diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift index df2fd46..714f03a 100644 --- a/Hutch/Views/Tickets/TicketDetailViewModel.swift +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -19,12 +19,18 @@ private struct TicketDetailPayload: Decodable, Sendable { let status: TicketStatus let resolution: TicketResolution? let authenticity: Authenticity + /// Null when the authenticated user is not subscribed to this ticket. + let subscription: SubscriptionIdPayload? let submitter: Entity let assignees: [Entity] let labels: [TicketLabel] let events: EventsPage } +struct SubscriptionIdPayload: Decodable, Sendable { + let id: Int +} + private struct EventsPage: Decodable, Sendable { let results: [TicketEvent] let cursor: String? @@ -52,6 +58,22 @@ private struct UpdatedStatusEvent: Decodable, Sendable { let eventType: String } +private struct TicketSubscriptionResponse: Decodable, Sendable { + let subscription: SubscriptionIdPayload +} + +private struct UpdateTicketResponse: Decodable, Sendable { + let updateTicket: TicketIdPayload +} + +private struct DeleteTicketResponse: Decodable, Sendable { + let deleteTicket: TicketIdPayload +} + +private struct TicketIdPayload: Decodable, Sendable { + let id: Int +} + private struct AssignUserResponse: Decodable, Sendable { let assignUser: MutationEventResponse } @@ -112,6 +134,9 @@ final class TicketDetailViewModel { private(set) var isLoading = false private(set) var isSubmitting = false private(set) var isPerformingAction = false + /// Whether the authenticated user receives email for this ticket. Mirrors + /// `Ticket.subscription`, which is null when not subscribed. + private(set) var isSubscribed = false private(set) var trackerLabels: [TicketLabel] = [] private(set) var rawTicketResponse: String? private(set) var cacheMetadata: CacheEntryMetadata? @@ -141,6 +166,35 @@ final class TicketDetailViewModel { return input } + /// Builds an `UpdateTicketInput` carrying only the fields that changed, so an + /// edit never overwrites a field the user did not touch. + static func ticketUpdateInput( + subject: String, + body: String, + currentSubject: String, + currentBody: String? + ) -> [String: any Sendable] { + var input: [String: any Sendable] = [:] + let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmedSubject != currentSubject { + input["subject"] = trimmedSubject + } + + if trimmedBody != (currentBody ?? "") { + if trimmedBody.isEmpty { + // A nil subscript assignment would drop the key and leave the old + // body in place instead of clearing it. + input.updateValue(Optional<String>.none as any Sendable, forKey: "body") + } else { + input["body"] = trimmedBody + } + } + + return input + } + init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) { self.ownerUsername = ownerUsername self.trackerName = trackerName @@ -164,6 +218,7 @@ final class TicketDetailViewModel { status resolution authenticity + subscription { id } submitter { canonicalName } assignees { canonicalName } labels { id name backgroundColor foregroundColor } @@ -233,6 +288,30 @@ final class TicketDetailViewModel { } """ + private static let updateTicketMutation = """ + mutation updateTicket($trackerId: Int!, $ticketId: Int!, $input: UpdateTicketInput!) { + updateTicket(trackerId: $trackerId, ticketId: $ticketId, input: $input) { id } + } + """ + + private static let deleteTicketMutation = """ + mutation deleteTicket($trackerId: Int!, $ticketId: Int!) { + deleteTicket(trackerId: $trackerId, ticketId: $ticketId) { id } + } + """ + + private static let ticketSubscribeMutation = """ + mutation ticketSubscribe($trackerId: Int!, $ticketId: Int!) { + subscription: ticketSubscribe(trackerId: $trackerId, ticketId: $ticketId) { id } + } + """ + + private static let ticketUnsubscribeMutation = """ + mutation ticketUnsubscribe($trackerId: Int!, $ticketId: Int!) { + subscription: ticketUnsubscribe(trackerId: $trackerId, ticketId: $ticketId) { id } + } + """ + private static let assignUserMutation = """ mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } @@ -420,6 +499,99 @@ final class TicketDetailViewModel { isPerformingAction = false } + /// Edits the ticket's subject and body. Returns true when the edit was sent, + /// including the no-op case where nothing changed. + @discardableResult + func updateTicket(subject: String, body: String) async -> Bool { + guard !isPerformingAction, let ticket else { return false } + + let input = Self.ticketUpdateInput( + subject: subject, + body: body, + currentSubject: ticket.title, + currentBody: ticket.description + ) + guard !input.isEmpty else { return true } + + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + do { + _ = try await client.execute( + service: .todo, + query: Self.updateTicketMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "input": input + ], + responseType: UpdateTicketResponse.self + ) + await invalidateAfterMutation() + await reloadTicketPreservingDebugState() + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + + /// Subscribes to or unsubscribes from email notifications for this ticket. + func toggleSubscription() async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + let wasSubscribed = isSubscribed + // Reflect the change immediately; the catch below puts it back if the + // mutation fails, so the control never lies about server state. + isSubscribed.toggle() + + do { + _ = try await client.execute( + service: .todo, + query: wasSubscribed ? Self.ticketUnsubscribeMutation : Self.ticketSubscribeMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId + ], + responseType: TicketSubscriptionResponse.self + ) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket")) + } catch { + isSubscribed = wasSubscribed + self.error = error.userFacingMessage + } + } + + /// Deletes the ticket. Returns true on success so the caller can pop the view. + @discardableResult + func deleteTicket() async -> Bool { + guard !isPerformingAction else { return false } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + do { + _ = try await client.execute( + service: .todo, + query: Self.deleteTicketMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId + ], + responseType: DeleteTicketResponse.self + ) + await invalidateAfterMutation() + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + func assignUser(username: String) async { guard !isPerformingAction else { return } isPerformingAction = true @@ -691,6 +863,7 @@ final class TicketDetailViewModel { labels: payload.labels ) ticket = updatedTicket + isSubscribed = payload.subscription != nil let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder) events = updatedEvents } diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index afee96d..650554c 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -242,6 +242,7 @@ struct TicketListView: View { trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) await vm.loadTickets() await vm.loadTrackerLabels() + await vm.loadSubscriptionState() } } } @@ -469,6 +470,20 @@ struct TicketListView: View { Divider() + if let viewModel { + Button { + Task { await viewModel.toggleSubscription() } + } label: { + Label( + viewModel.isSubscribed ? "Unsubscribe" : "Subscribe", + systemImage: viewModel.isSubscribed ? "bell.slash" : "bell" + ) + } + .disabled(viewModel.isPerformingAction) + } + + Divider() + if let trackerURL = SRHTWebURL.tracker(tracker) { Button { openURL(trackerURL) diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift index 1f44993..2727dcc 100644 --- a/Hutch/Views/Tickets/TicketListViewModel.swift +++ b/Hutch/Views/Tickets/TicketListViewModel.swift @@ -33,6 +33,23 @@ private struct LabelMutationResponse: Decodable, Sendable { let unlabelTicket: EventRef? } +private struct TrackerSubscriptionStateResponse: Decodable, Sendable { + let tracker: TrackerSubscriptionWrapper +} + +private struct TrackerSubscriptionWrapper: Decodable, Sendable { + /// Null when the authenticated user is not subscribed to this tracker. + let subscription: TrackerSubscriptionIdPayload? +} + +private struct TrackerSubscriptionResponse: Decodable, Sendable { + let subscription: TrackerSubscriptionIdPayload +} + +private struct TrackerSubscriptionIdPayload: Decodable, Sendable { + let id: Int +} + private struct TrackerLabelsResponse: Decodable, Sendable { let tracker: TrackerLabelsWrapper } @@ -90,6 +107,9 @@ final class TicketListViewModel { private(set) var isLoadingMore = false private(set) var isCreatingTicket = false private(set) var isPerformingAction = false + /// Whether the authenticated user receives email for this tracker. Mirrors + /// `Tracker.subscription`, which is null when not subscribed. + private(set) var isSubscribed = false private(set) var trackerLabels: [TicketLabel] = [] private(set) var recentSearches: [ScopedSearchHistoryEntry] private(set) var savedFilters: [SavedTicketFilter] @@ -216,6 +236,28 @@ final class TicketListViewModel { } """ + /// Kept separate from `query` above, which is paginated and cached — the + /// subscription is per-user state and should not ride along in page payloads. + private static let trackerSubscriptionQuery = """ + query trackerSubscription($rid: ID!) { + tracker(rid: $rid) { + subscription { id } + } + } + """ + + private static let trackerSubscribeMutation = """ + mutation trackerSubscribe($trackerId: Int!) { + subscription: trackerSubscribe(trackerId: $trackerId) { id } + } + """ + + private static let trackerUnsubscribeMutation = """ + mutation trackerUnsubscribe($trackerId: Int!, $tickets: Boolean!) { + subscription: trackerUnsubscribe(trackerId: $trackerId, tickets: $tickets) { id } + } + """ + private static let trackerLabelsQuery = """ query trackerLabels($rid: ID!) { tracker(rid: $rid) { @@ -520,6 +562,52 @@ final class TicketListViewModel { isPerformingAction = false } + /// Reads whether the user is subscribed to this tracker. Uncached: it is + /// per-user state that must be accurate the moment the menu opens. + func loadSubscriptionState() async { + do { + let response = try await client.execute( + service: .todo, + query: Self.trackerSubscriptionQuery, + variables: ["rid": trackerRid], + responseType: TrackerSubscriptionStateResponse.self + ) + isSubscribed = response.tracker.subscription != nil + } catch { + // Leave the last known value alone; the toggle reports its own errors. + } + } + + /// Subscribes to or unsubscribes from email notifications for this tracker. + /// Unsubscribing leaves individual ticket subscriptions intact. + func toggleSubscription() async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + defer { isPerformingAction = false } + + let wasSubscribed = isSubscribed + isSubscribed.toggle() + + var variables: [String: any Sendable] = ["trackerId": trackerId] + if wasSubscribed { + variables["tickets"] = false + } + + do { + _ = try await client.execute( + service: .todo, + query: wasSubscribed ? Self.trackerUnsubscribeMutation : Self.trackerSubscribeMutation, + variables: variables, + responseType: TrackerSubscriptionResponse.self + ) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker")) + } catch { + isSubscribed = wasSubscribed + self.error = error.userFacingMessage + } + } + func loadTrackerLabels() async { do { let cached = try await client.executeCached( |
