summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Tickets')
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift133
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift173
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift15
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift88
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(