summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets/TicketListViewModel.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Tickets/TicketListViewModel.swift')
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift215
1 files changed, 215 insertions, 0 deletions
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
new file mode 100644
index 0000000..916037e
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -0,0 +1,215 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TrackerTicketsResponse: Decodable, Sendable {
+ let user: UserTrackerWrapper
+}
+
+private struct UserTrackerWrapper: Decodable, Sendable {
+ let tracker: TrackerTicketsWrapper
+}
+
+private struct TrackerTicketsWrapper: Decodable, Sendable {
+ let tickets: TicketsPage
+}
+
+private struct TicketsPage: Decodable, Sendable {
+ let results: [TicketSummary]
+ let cursor: String?
+}
+
+// MARK: - Filter
+
+enum TicketFilter: String, CaseIterable, Sendable {
+ case open = "Open"
+ case resolved = "Resolved"
+ case all = "All"
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TicketListViewModel {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+
+ private(set) var tickets: [TicketSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isCreatingTicket = false
+ var error: String?
+ var filter: TicketFilter = .open
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) {
+ self.ownerUsername = ownerUsername
+ self.trackerName = trackerName
+ self.trackerId = trackerId
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ tickets(cursor: $cursor) {
+ results {
+ id
+ title: subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ """
+
+ private static let submitTicketMutation = """
+ mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
+ submitTicket(trackerId: $trackerId, input: $input) {
+ id
+ title: subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ }
+ """
+
+ // MARK: - Computed
+
+ /// Tickets filtered by the selected status filter.
+ var filteredTickets: [TicketSummary] {
+ switch filter {
+ case .open:
+ tickets.filter { $0.status.isOpen }
+ case .resolved:
+ tickets.filter { !$0.status.isOpen }
+ case .all:
+ tickets
+ }
+ }
+
+ // MARK: - Public API
+
+ func loadTickets() async {
+ isLoading = true
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil)
+ tickets = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadMoreIfNeeded(currentItem: TicketSummary) async {
+ guard let last = tickets.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor)
+ tickets.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func createTicket(subject: String, body: String) async -> TicketSummary? {
+ guard !isCreatingTicket else { return nil }
+
+ let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedSubject.isEmpty else {
+ error = "Enter a ticket title."
+ return nil
+ }
+
+ isCreatingTicket = true
+ error = nil
+ defer { isCreatingTicket = false }
+
+ var input: [String: any Sendable] = [
+ "subject": trimmedSubject
+ ]
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedBody.isEmpty {
+ input["body"] = trimmedBody
+ }
+ let variables: [String: any Sendable] = [
+ "trackerId": trackerId,
+ "input": input
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.submitTicketMutation,
+ variables: variables,
+ responseType: SubmitTicketResponse.self
+ )
+ let ticket = result.submitTicket
+ tickets.insert(ticket, at: 0)
+ return ticket
+ } catch {
+ self.error = "Couldn’t create the ticket. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?) async throws -> TicketsPage {
+ var variables: [String: any Sendable] = [
+ "owner": ownerUsername,
+ "tracker": trackerName
+ ]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.query,
+ variables: variables,
+ responseType: TrackerTicketsResponse.self
+ )
+ return result.user.tracker.tickets
+ }
+
+ private struct SubmitTicketResponse: Decodable, Sendable {
+ let submitTicket: TicketSummary
+ }
+}