Hutch/Views/Tickets/TicketDetailView.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/Views/Tickets/TicketDetailView.swift history · blame · raw

828 lines · 30297 bytes

  1import SwiftUI
  2import WebKit
  3
  4struct TicketDetailView: View {
  5    let ownerUsername: String
  6    let trackerName: String
  7    let trackerId: Int
  8    let trackerRid: String
  9    let ticketId: Int
 10
 11    @Environment(AppState.self) private var appState
 12    @Environment(\.colorScheme) private var colorScheme
 13    @State private var viewModel: TicketDetailViewModel?
 14
 15    // Sheet state
 16    @State private var showResolveSheet = false
 17    @State private var showAssignSheet = false
 18    @State private var showLabelsSheet = false
 19
 20    // Comment composer mode
 21    @State private var commentMode: CommentMode = .write
 22
 23    private enum CommentMode: String, CaseIterable {
 24        case write = "Write"
 25        case preview = "Preview"
 26    }
 27
 28    var body: some View {
 29        Group {
 30            if let viewModel {
 31                detailContent(viewModel)
 32            } else {
 33                SRHTLoadingStateView(message: "Loading ticket…")
 34            }
 35        }
 36        .navigationTitle("#\(ticketId)")
 37        .navigationBarTitleDisplayMode(.inline)
 38        .toolbar {
 39            ToolbarItemGroup(placement: .topBarTrailing) {
 40                SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) {
 41                    Image(systemName: "square.and.arrow.up")
 42                }
 43
 44                if let viewModel, viewModel.ticket != nil {
 45                    actionsMenu(viewModel)
 46                }
 47            }
 48        }
 49        .task {
 50            if viewModel == nil {
 51                let vm = TicketDetailViewModel(
 52                    ownerUsername: ownerUsername,
 53                    trackerName: trackerName,
 54                    trackerId: trackerId,
 55                    trackerRid: trackerRid,
 56                    ticketId: ticketId,
 57                    client: appState.client
 58                )
 59                viewModel = vm
 60                await vm.loadTicket()
 61            }
 62        }
 63    }
 64
 65    // MARK: - Actions Menu
 66
 67    @ViewBuilder
 68    private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
 69        Menu {
 70            if let ticket = viewModel.ticket {
 71                if ticket.status == .resolved {
 72                    Button {
 73                        Task {
 74                            await viewModel.updateStatus(
 75                                status: .reported,
 76                                resolution: .unresolved
 77                            )
 78                        }
 79                    } label: {
 80                        SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
 81                    }
 82                } else {
 83                    Button {
 84                        showResolveSheet = true
 85                    } label: {
 86                        SwiftUI.Label("Resolve", systemImage: "checkmark.circle")
 87                    }
 88                }
 89            }
 90
 91            Button {
 92                showAssignSheet = true
 93            } label: {
 94                SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus")
 95            }
 96
 97            Button {
 98                showLabelsSheet = true
 99                Task { await viewModel.loadTrackerLabels() }
100            } label: {
101                SwiftUI.Label("Manage Labels", systemImage: "tag")
102            }
103        } label: {
104            Image(systemName: "ellipsis.circle")
105        }
106        .sheet(isPresented: $showResolveSheet) {
107            ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
108                .presentationDetents([.medium])
109        }
110        .sheet(isPresented: $showAssignSheet) {
111            AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet)
112                .presentationDetents([.medium])
113        }
114        .sheet(isPresented: $showLabelsSheet) {
115            LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
116                .presentationDetents([.medium])
117        }
118    }
119
120    // MARK: - Detail Content
121
122    @ViewBuilder
123    private func detailContent(_ viewModel: TicketDetailViewModel) -> some View {
124        @Bindable var vm = viewModel
125
126        if viewModel.isLoading, viewModel.ticket == nil {
127            SRHTLoadingStateView(message: "Loading ticket…")
128        } else if let error = viewModel.error, viewModel.ticket == nil {
129            SRHTErrorStateView(
130                title: "Couldn't Load Ticket",
131                message: error,
132                retryAction: { await viewModel.loadTicket() }
133            )
134        } else if let ticket = viewModel.ticket {
135            ScrollView {
136                VStack(alignment: .leading, spacing: 0) {
137                    // Header
138                    ticketHeader(ticket)
139
140                    Divider()
141                        .padding(.vertical, 12)
142
143                    // Description
144                    if let description = ticket.description, !description.isEmpty {
145                        MarkdownContentView(markdown: description)
146                            .padding(.horizontal)
147                            .padding(.bottom, 16)
148
149                        Divider()
150                            .padding(.bottom, 12)
151                    }
152
153                    // Event timeline
154                    if !viewModel.events.isEmpty {
155                        Text("Activity")
156                            .font(.headline)
157                            .padding(.horizontal)
158                            .padding(.bottom, 8)
159
160                        LazyVStack(alignment: .leading, spacing: 0) {
161                            ForEach(viewModel.events) { event in
162                                EventRow(
163                                    event: event,
164                                    ticketSubmitter: viewModel.ticket?.submitter.canonicalName,
165                                    ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName }
166                                )
167                                if event.id != viewModel.events.last?.id {
168                                    Divider()
169                                        .padding(.leading, 40)
170                                }
171                            }
172                        }
173
174                        Divider()
175                            .padding(.vertical, 12)
176                    }
177
178                    // Comment input
179                    commentInput(viewModel)
180                }
181            }
182            .srhtErrorBanner(error: $vm.error)
183            .refreshable {
184                await viewModel.loadTicket()
185            }
186        }
187    }
188
189    // MARK: - Header
190
191    @ViewBuilder
192    private func ticketHeader(_ ticket: TicketDetail) -> some View {
193        VStack(alignment: .leading, spacing: 8) {
194            Text(ticket.title)
195                .font(.title3.weight(.semibold))
196
197            HStack(spacing: 8) {
198                TicketStatusIcon(status: ticket.status)
199                Text(ticket.status.displayName)
200                    .font(.subheadline.weight(.medium))
201
202                if ticket.status == .resolved, let resolution = ticket.resolution {
203                    Text("(\(resolution.displayName))")
204                        .font(.subheadline)
205                        .foregroundStyle(.secondary)
206                }
207            }
208
209            HStack(spacing: 4) {
210                Text("Opened by")
211                    .foregroundStyle(.secondary)
212                Text(ticket.submitter.canonicalName)
213                    .fontWeight(.medium)
214                Text(ticket.created.relativeDescription)
215                    .foregroundStyle(.tertiary)
216            }
217            .font(.caption)
218
219            if !ticket.assignees.isEmpty {
220                HStack(spacing: 4) {
221                    Image(systemName: "person.fill")
222                        .font(.caption2)
223                        .foregroundStyle(.secondary)
224                    Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
225                        .font(.caption)
226                        .foregroundStyle(.secondary)
227                }
228            }
229
230            if !ticket.labels.isEmpty {
231                FlowLayout(spacing: 4) {
232                    ForEach(ticket.labels) { label in
233                        LabelPill(label: label)
234                    }
235                }
236            }
237        }
238        .padding()
239    }
240
241    // MARK: - Comment Input
242
243    @ViewBuilder
244    private func commentInput(_ viewModel: TicketDetailViewModel) -> some View {
245        @Bindable var vm = viewModel
246
247        VStack(alignment: .leading, spacing: 8) {
248            Text("New Comment")
249                .font(.headline)
250
251            Picker("Mode", selection: $commentMode) {
252                ForEach(CommentMode.allCases, id: \.self) { mode in
253                    Text(mode.rawValue).tag(mode)
254                }
255            }
256            .pickerStyle(.segmented)
257
258            if commentMode == .write {
259                TextField("Write your comment…", text: $vm.commentText, axis: .vertical)
260                    .textFieldStyle(.roundedBorder)
261                    .lineLimit(3...8)
262            } else {
263                // Markdown preview
264                if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
265                    Text("Nothing to preview")
266                        .font(.subheadline)
267                        .foregroundStyle(.secondary)
268                        .frame(maxWidth: .infinity, minHeight: 80, alignment: .center)
269                        .background(Color(.secondarySystemBackground))
270                        .clipShape(RoundedRectangle(cornerRadius: 8))
271                } else {
272                    MarkdownContentView(markdown: viewModel.commentText)
273                        .frame(minHeight: 80, maxHeight: 200)
274                    .clipShape(RoundedRectangle(cornerRadius: 8))
275                }
276            }
277
278            HStack {
279                Spacer()
280                Button {
281                    Task { await viewModel.submitComment() }
282                } label: {
283                    if viewModel.isSubmitting {
284                        ProgressView()
285                            .controlSize(.small)
286                    } else {
287                        Text("Post Comment")
288                    }
289                }
290                .buttonStyle(.borderedProminent)
291                .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
292            }
293        }
294        .padding()
295    }
296}
297
298// MARK: - Self-Sizing Markdown Web View
299
300/// Renders markdown as HTML in a WKWebView that auto-sizes its height to
301/// fit the rendered content. Reuses the same `markdownToHTML` converter and
302/// styling as the README renderer.
303private struct MarkdownContentView: View {
304    let markdown: String
305    @Environment(\.colorScheme) private var colorScheme
306    @State private var renderedHTML: String?
307
308    var body: some View {
309        Group {
310            if let renderedHTML {
311                HTMLWebView(
312                    html: renderedHTML,
313                    colorScheme: colorScheme,
314                    style: .commentPreview
315                )
316            } else {
317                SRHTLoadingStateView(message: "Preparing content…")
318                    .frame(minHeight: 80)
319            }
320        }
321        .task(id: markdown) {
322            if renderedHTML != nil {
323                try? await Task.sleep(for: .milliseconds(150))
324                guard !Task.isCancelled else { return }
325            }
326
327            let html = await Task.detached(priority: .userInitiated) {
328                markdownToHTML(markdown)
329            }.value
330            guard !Task.isCancelled else { return }
331            renderedHTML = html
332        }
333    }
334}
335
336// MARK: - Event Row
337
338private struct EventRow: View {
339    let event: TicketEvent
340    let ticketSubmitter: String?
341    let ticketAssignees: [String]?
342    @State private var isShowingSystemStatusInfo = false
343
344    var body: some View {
345        ForEach(event.changes) { change in
346            HStack(alignment: .top, spacing: 12) {
347                Image(systemName: icon(for: change))
348                    .foregroundStyle(color(for: change))
349                    .frame(width: 24)
350                    .padding(.top, 2)
351
352                VStack(alignment: .leading, spacing: 4) {
353                    HStack {
354                        if kind(for: change) == .comment {
355                            Text(change.author?.canonicalName ?? "")
356                                .font(.subheadline.weight(.medium))
357                        } else {
358                            let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
359                            if descriptionText.hasPrefix("System") {
360                                HStack(spacing: 4) {
361                                    Text(descriptionText)
362                                        .font(.subheadline)
363                                        .foregroundStyle(.secondary)
364                                    Button {
365                                        isShowingSystemStatusInfo = true
366                                    } label: {
367                                        Image(systemName: "info.circle")
368                                            .foregroundStyle(.gray)
369                                    }
370                                    .buttonStyle(.plain)
371                                }
372                            } else {
373                                Text(descriptionText)
374                                    .font(.subheadline)
375                                    .foregroundStyle(.secondary)
376                            }
377                        }
378                        Spacer()
379                        Text(event.created.relativeDescription)
380                            .font(.caption)
381                            .foregroundStyle(.tertiary)
382                    }
383                    if kind(for: change) == .comment, let text = change.text {
384                        MarkdownContentView(markdown: text)
385                    }
386                }
387            }
388            .padding(.horizontal)
389            .padding(.vertical, 8)
390            .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
391                Button("OK", role: .cancel) {}
392            } message: {
393                Text("This status change was recorded automatically or without a named user attached to the event.")
394            }
395        }
396    }
397
398    private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
399        let eventKind = kind(for: change)
400        let authorName: String
401
402        if let commentAuthor = event.changes.first(where: {
403            kind(for: $0) == .comment && $0.author != nil
404        })?.author?.canonicalName {
405            authorName = commentAuthor
406        } else {
407            switch eventKind {
408            case .created:
409                authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
410            case .statusChange:
411                authorName = "System"
412            case .labelAdded, .labelRemoved, .labelUpdated:
413                authorName = change.labeler?.canonicalName ?? "Someone"
414            case .assigned, .unassigned:
415                authorName = change.assigner?.canonicalName ?? "Someone"
416            case .comment:
417                authorName = change.author?.canonicalName ?? "Someone"
418            case .ticketMention, .userMention:
419                authorName = change.author?.canonicalName
420                    ?? change.assigner?.canonicalName
421                    ?? change.labeler?.canonicalName
422                    ?? "Someone"
423            case .unknown:
424                authorName = change.author?.canonicalName
425                    ?? change.assigner?.canonicalName
426                    ?? change.labeler?.canonicalName
427                    ?? change.assignee?.canonicalName
428                    ?? change.mentioned?.canonicalName
429                    ?? ticketAssignees?.first
430                    ?? "Someone"
431            }
432        }
433
434        switch eventKind {
435        case .statusChange:
436            let oldStatus = change.oldStatus?.displayName ?? "unknown"
437            let newStatus = change.newStatus?.displayName ?? "unknown"
438            return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
439        case .labelUpdated, .labelAdded:
440            let labelName = change.label?.name ?? "a label"
441            let verb = eventKind == .labelAdded ? "added" : "updated"
442            return "\(authorName) \(verb) label \"\(labelName)\""
443        case .labelRemoved:
444            let labelName = change.label?.name ?? "a label"
445            return "\(authorName) removed label \"\(labelName)\""
446        case .assigned:
447            let assigneeName = change.assignee?.canonicalName ?? "someone"
448            return "\(authorName) assigned \(assigneeName)"
449        case .unassigned:
450            let assigneeName = change.assignee?.canonicalName ?? "someone"
451            return "\(authorName) unassigned \(assigneeName)"
452        case .ticketMention:
453            if let ticketId = change.mentioned?.id {
454                return "\(authorName) mentioned ticket #\(ticketId)"
455            }
456            return "\(authorName) mentioned another ticket"
457        case .userMention:
458            let user = change.mentioned?.canonicalName ?? "someone"
459            return "\(authorName) mentioned \(user)"
460        case .created:
461            return "\(authorName) opened this ticket"
462        case .comment:
463            return "\(authorName) commented"
464        case .unknown:
465            return "\(authorName) updated this ticket"
466        }
467    }
468
469    private func icon(for change: EventChange) -> String {
470        switch kind(for: change) {
471        case .comment:
472            "text.bubble"
473        case .statusChange:
474            "arrow.triangle.2.circlepath"
475        case .labelAdded, .labelRemoved, .labelUpdated:
476            "tag"
477        case .assigned:
478            "person.badge.plus"
479        case .unassigned:
480            "person.badge.minus"
481        case .ticketMention, .userMention:
482            "at"
483        case .created:
484            "plus.circle"
485        case .unknown:
486            "circle.fill"
487        }
488    }
489
490    private func color(for change: EventChange) -> Color {
491        switch kind(for: change) {
492        case .comment:
493            .blue
494        case .statusChange:
495            change.newStatus == .resolved ? .green : .orange
496        case .labelAdded, .labelRemoved, .labelUpdated:
497            .purple
498        case .assigned, .unassigned:
499            .cyan
500        case .ticketMention, .userMention:
501            .indigo
502        case .created:
503            .green
504        case .unknown:
505            .gray
506        }
507    }
508
509    private func kind(for change: EventChange) -> EventKind {
510        switch change.eventType {
511        case "COMMENT", "Comment":
512            .comment
513        case "STATUS_CHANGE", "StatusChange":
514            .statusChange
515        case "LABEL_UPDATE", "LabelUpdate":
516            .labelUpdated
517        case "LABEL_ADDED", "LabelAdded":
518            .labelAdded
519        case "LABEL_REMOVED", "LabelRemoved":
520            .labelRemoved
521        case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
522            .assigned
523        case "UNASSIGNED_USER", "UnassignedUser":
524            .unassigned
525        case "TICKET_MENTION", "TicketMention":
526            .ticketMention
527        case "USER_MENTION", "UserMention":
528            .userMention
529        case "CREATED", "Created":
530            .created
531        default:
532            .unknown
533        }
534    }
535
536    private enum EventKind: Equatable {
537        case comment
538        case statusChange
539        case labelUpdated
540        case labelAdded
541        case labelRemoved
542        case assigned
543        case unassigned
544        case ticketMention
545        case userMention
546        case created
547        case unknown
548    }
549}
550
551// MARK: - Resolve Sheet
552
553private struct ResolveSheet: View {
554    let viewModel: TicketDetailViewModel
555    @Binding var isPresented: Bool
556    @State private var selectedResolution: TicketResolution = .fixed
557
558    private static let resolutionOptions: [TicketResolution] = [
559        .closed, .fixed, .implemented, .wontFix,
560        .byDesign, .invalid, .duplicate, .notOurBug
561    ]
562
563    var body: some View {
564        NavigationStack {
565            Form {
566                Section("Resolution") {
567                    Picker("Resolution", selection: $selectedResolution) {
568                        ForEach(Self.resolutionOptions, id: \.self) { resolution in
569                            Text(resolution.displayName).tag(resolution)
570                        }
571                    }
572                    .pickerStyle(.inline)
573                    .labelsHidden()
574                }
575            }
576            .navigationTitle("Resolve Ticket")
577            .navigationBarTitleDisplayMode(.inline)
578            .toolbar {
579                ToolbarItem(placement: .cancellationAction) {
580                    Button("Cancel") { isPresented = false }
581                }
582                ToolbarItem(placement: .confirmationAction) {
583                    Button("Mark Resolved") {
584                        Task {
585                            await viewModel.updateStatus(
586                                status: .resolved,
587                                resolution: selectedResolution
588                            )
589                            if viewModel.error == nil {
590                                isPresented = false
591                            }
592                        }
593                    }
594                    .disabled(viewModel.isPerformingAction)
595                }
596            }
597            .overlay {
598                if viewModel.isPerformingAction {
599                    ProgressView()
600                }
601            }
602        }
603    }
604}
605
606// MARK: - Assign Sheet
607
608private struct AssignSheet: View {
609    let viewModel: TicketDetailViewModel
610    @Binding var isPresented: Bool
611    @State private var username = ""
612
613    var body: some View {
614        NavigationStack {
615            Form {
616                // Current assignees with remove buttons
617                if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
618                    Section("Current Assignees") {
619                        ForEach(ticket.assignees, id: \.canonicalName) { assignee in
620                            HStack {
621                                Text(assignee.canonicalName)
622                                Spacer()
623                                Button(role: .destructive) {
624                                    Task {
625                                        await viewModel.unassignUser(
626                                            username: assignee.canonicalName
627                                        )
628                                    }
629                                } label: {
630                                    Image(systemName: "minus.circle.fill")
631                                        .foregroundStyle(.red)
632                                }
633                                .buttonStyle(.plain)
634                            }
635                        }
636                    }
637                }
638
639                Section("Add Assignee") {
640                    TextField("Username or ~username", text: $username)
641                        .textContentType(.username)
642                        .autocorrectionDisabled()
643                        .textInputAutocapitalization(.never)
644
645                    Button("Add Assignee") {
646                        let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
647                        guard !name.isEmpty else { return }
648                        Task {
649                            await viewModel.assignUser(username: name)
650                            username = ""
651                        }
652                    }
653                    .disabled(
654                        username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
655                        || viewModel.isPerformingAction
656                    )
657                }
658            }
659            .navigationTitle("Assignees")
660            .navigationBarTitleDisplayMode(.inline)
661            .toolbar {
662                ToolbarItem(placement: .confirmationAction) {
663                    Button("Done") { isPresented = false }
664                }
665            }
666            .overlay {
667                if viewModel.isPerformingAction {
668                    ProgressView()
669                }
670            }
671        }
672    }
673}
674
675// MARK: - Labels Sheet
676
677private struct LabelsSheet: View {
678    let viewModel: TicketDetailViewModel
679    @Binding var isPresented: Bool
680    @State private var showCreateLabel = false
681
682    var body: some View {
683        NavigationStack {
684            Group {
685                if viewModel.trackerLabels.isEmpty {
686                    if viewModel.isPerformingAction {
687                        ProgressView()
688                    } else {
689                        ContentUnavailableView(
690                            "No Labels",
691                            systemImage: "tag",
692                            description: Text("This tracker has no labels defined.")
693                        )
694                    }
695                } else {
696                    List {
697                        ForEach(viewModel.trackerLabels) { label in
698                            LabelToggleRow(
699                                label: label,
700                                isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
701                                isLoading: viewModel.isPerformingAction
702                            ) { shouldApply in
703                                Task {
704                                    if shouldApply {
705                                        await viewModel.labelTicket(labelId: label.id)
706                                    } else {
707                                        await viewModel.unlabelTicket(labelId: label.id)
708                                    }
709                                }
710                            }
711                        }
712                    }
713                }
714            }
715            .navigationTitle("Labels")
716            .navigationBarTitleDisplayMode(.inline)
717            .toolbar {
718                ToolbarItem(placement: .cancellationAction) {
719                    Button("Done") { isPresented = false }
720                }
721                ToolbarItem(placement: .primaryAction) {
722                    Button {
723                        showCreateLabel = true
724                    } label: {
725                        SwiftUI.Label("New Label", systemImage: "plus")
726                    }
727                }
728            }
729            .sheet(isPresented: $showCreateLabel) {
730                CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
731                    .presentationDetents([.medium])
732            }
733        }
734    }
735}
736
737// MARK: - Create Label Sheet
738
739private struct CreateLabelSheet: View {
740    let viewModel: TicketDetailViewModel
741    @Binding var isPresented: Bool
742    @State private var labelName = ""
743    @State private var backgroundColor = Color.blue
744    @State private var foregroundColor = Color.white
745
746    var body: some View {
747        NavigationStack {
748            Form {
749                Section("Label Details") {
750                    TextField("Label name", text: $labelName)
751                        .autocorrectionDisabled()
752                }
753
754                Section("Colors") {
755                    ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
756                    ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
757                }
758
759                Section("Preview") {
760                    HStack {
761                        Spacer()
762                        Text(labelName.isEmpty ? "Label" : labelName)
763                            .font(.caption2.weight(.medium))
764                            .padding(.horizontal, 6)
765                            .padding(.vertical, 2)
766                            .background(backgroundColor)
767                            .foregroundStyle(foregroundColor)
768                            .clipShape(Capsule())
769                        Spacer()
770                    }
771                }
772            }
773            .navigationTitle("New Label")
774            .navigationBarTitleDisplayMode(.inline)
775            .toolbar {
776                ToolbarItem(placement: .cancellationAction) {
777                    Button("Cancel") { isPresented = false }
778                }
779                ToolbarItem(placement: .confirmationAction) {
780                    Button("Create Label") {
781                        Task {
782                            await viewModel.createLabel(
783                                name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
784                                backgroundColor: backgroundColor.hexString,
785                                foregroundColor: foregroundColor.hexString
786                            )
787                            if viewModel.error == nil {
788                                isPresented = false
789                            }
790                        }
791                    }
792                    .disabled(
793                        labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
794                        || viewModel.isPerformingAction
795                    )
796                }
797            }
798            .overlay {
799                if viewModel.isPerformingAction {
800                    ProgressView()
801                }
802            }
803        }
804    }
805}
806
807private struct LabelToggleRow: View {
808    let label: TicketLabel
809    let isApplied: Bool
810    let isLoading: Bool
811    let onToggle: (Bool) -> Void
812
813    var body: some View {
814        Button {
815            onToggle(!isApplied)
816        } label: {
817            HStack {
818                LabelPill(label: label)
819                Spacer()
820                if isApplied {
821                    Image(systemName: "checkmark")
822                        .foregroundStyle(.blue)
823                }
824            }
825        }
826        .disabled(isLoading)
827    }
828}