From 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Tue, 17 Mar 2026 23:19:43 -0500 Subject: v1.0 --- Hutch/Views/Tickets/TicketListViewModel.swift | 215 ++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 Hutch/Views/Tickets/TicketListViewModel.swift (limited to 'Hutch/Views/Tickets/TicketListViewModel.swift') 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 + } +} -- cgit v1.2.3