summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets/TicketDetailViewModel.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Tickets/TicketDetailViewModel.swift')
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift173
1 files changed, 173 insertions, 0 deletions
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
}