summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Tickets')
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift51
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift80
2 files changed, 127 insertions, 4 deletions
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index f553bd8..5ca8618 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -132,7 +132,7 @@ struct TicketDetailView: View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Header
- ticketHeader(ticket)
+ ticketHeader(ticket, viewModel: viewModel)
Divider()
.padding(.vertical, 12)
@@ -186,10 +186,16 @@ struct TicketDetailView: View {
// MARK: - Header
@ViewBuilder
- private func ticketHeader(_ ticket: TicketDetail) -> some View {
+ private func ticketHeader(_ ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
VStack(alignment: .leading, spacing: 8) {
- Text(ticket.title)
- .font(.title3.weight(.semibold))
+ HStack(alignment: .top, spacing: 12) {
+ Text(ticket.title)
+ .font(.title3.weight(.semibold))
+
+ Spacer(minLength: 12)
+
+ assignToMeButton(ticket: ticket, viewModel: viewModel)
+ }
HStack(spacing: 8) {
TicketStatusIcon(status: ticket.status)
@@ -235,6 +241,43 @@ struct TicketDetailView: View {
.padding()
}
+ @ViewBuilder
+ private func assignToMeButton(ticket: TicketDetail, viewModel: TicketDetailViewModel) -> some View {
+ if let currentUser = appState.currentUser {
+ let isAssignedToCurrentUser = ticket.assignees.contains {
+ TicketDetailViewModel.matchesAssignee($0, user: currentUser)
+ }
+
+ if isAssignedToCurrentUser {
+ Label("Assigned to you", systemImage: "checkmark.circle.fill")
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .background(Color(.secondarySystemFill), in: Capsule())
+ } else {
+ Button {
+ Task {
+ await viewModel.assignToCurrentUser(currentUser)
+ }
+ } label: {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ .controlSize(.small)
+ .frame(minWidth: 88)
+ } else {
+ Text("Assign to Me")
+ .font(.caption.weight(.semibold))
+ .frame(minWidth: 88)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(viewModel.isPerformingAction)
+ }
+ }
+ }
+
// MARK: - Comment Input
@ViewBuilder
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index 90e33aa..660715b 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -433,6 +433,64 @@ final class TicketDetailViewModel {
isPerformingAction = false
}
+ func assignToCurrentUser(_ user: User) async {
+ guard !isPerformingAction, let currentTicket = ticket else { return }
+
+ let currentAssignees = currentTicket.assignees
+ let currentEntity = Entity(canonicalName: user.canonicalName)
+ guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
+ return
+ }
+
+ isPerformingAction = true
+ error = nil
+
+ ticket = TicketDetail(
+ id: currentTicket.id,
+ created: currentTicket.created,
+ updated: currentTicket.updated,
+ title: currentTicket.title,
+ description: currentTicket.description,
+ status: currentTicket.status,
+ resolution: currentTicket.resolution,
+ authenticity: currentTicket.authenticity,
+ submitter: currentTicket.submitter,
+ assignees: currentAssignees + [currentEntity],
+ labels: currentTicket.labels
+ )
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.assignUserMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "userId": user.id
+ ],
+ responseType: AssignUserResponse.self
+ )
+ await loadTicket()
+ } catch {
+ ticket = TicketDetail(
+ id: currentTicket.id,
+ created: currentTicket.created,
+ updated: currentTicket.updated,
+ title: currentTicket.title,
+ description: currentTicket.description,
+ status: currentTicket.status,
+ resolution: currentTicket.resolution,
+ authenticity: currentTicket.authenticity,
+ submitter: currentTicket.submitter,
+ assignees: currentAssignees,
+ labels: currentTicket.labels
+ )
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
func unassignUser(username: String) async {
guard !isPerformingAction else { return }
isPerformingAction = true
@@ -558,4 +616,26 @@ final class TicketDetailViewModel {
isPerformingAction = false
}
+ static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
+ let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
+ let userCanonical = normalizedCanonicalName(user.canonicalName)
+ if assigneeCanonical == userCanonical {
+ return true
+ }
+ return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
+ }
+
+ private static func normalizedCanonicalName(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("~") {
+ return trimmed
+ }
+ return "~\(trimmed)"
+ }
+
+ private static func normalizedUsername(_ value: String) -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+ }
+
}