gitbay/Views/Issues/LabelListView.swift
120 lines · 4562 bytes
1import SwiftUI
2
3/// A repository's or an org's labels: their colour, how many issues
4/// carry each, and the controls to create, recolour and remove them.
5struct LabelListView: View {
6
7 @State private var model: LabelListViewModel
8 @State private var newLabel = ""
9 @State private var pendingRemoval: IssueLabel?
10
11 init(client: GitbayClient, repo: String) {
12 self.init(client: client, owner: .repo(repo))
13 }
14
15 init(client: GitbayClient, owner: LabelOwner) {
16 _model = State(initialValue: LabelListViewModel(client: client, owner: owner))
17 }
18
19 private var colors: LabelColors { LabelColors(model.state.value ?? []) }
20
21 var body: some View {
22 List {
23 if let actionError = model.actionError {
24 Section { GBNotice(actionError) }
25 }
26
27 Section("New label") {
28 HStack {
29 TextField("Label", text: $newLabel)
30 .autocorrectionDisabled()
31 .textInputAutocapitalization(.never)
32 .accessibilityIdentifier("label-new-name")
33 Button {
34 let name = newLabel.trimmingCharacters(in: .whitespaces)
35 newLabel = ""
36 Task { await model.setLabel(name, color: nil) }
37 } label: {
38 Image(systemName: "plus.circle.fill")
39 }
40 .disabled(
41 newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working
42 )
43 .accessibilityIdentifier("label-create-submit")
44 }
45 }
46
47 ForEach(model.state.value ?? []) { label in
48 // A repository sees its org's labels but cannot change
49 // them; the org's own screen can.
50 LabelRow(label: label, color: colors.color(label.name),
51 manageable: !label.org || model.owner.isOrg, working: model.working) {
52 hex in
53 Task { await model.setLabel(label.name, color: hex) }
54 } remove: {
55 pendingRemoval = label
56 }
57 }
58 }
59 .overlay { LoadStateOverlay(state: model.state) }
60 .navigationTitle("Labels")
61 .navigationBarTitleDisplayMode(.inline)
62 .task { await model.load() }
63 .refreshable { await model.load() }
64 .confirmationDialog(
65 "Remove \(pendingRemoval?.name ?? "")?",
66 isPresented: .init(
67 get: { pendingRemoval != nil },
68 set: { if !$0 { pendingRemoval = nil } }
69 ),
70 titleVisibility: .visible
71 ) {
72 Button("Remove", role: .destructive) {
73 guard let label = pendingRemoval else { return }
74 pendingRemoval = nil
75 Task { await model.remove(label.name) }
76 }
77 Button("Cancel", role: .cancel) { pendingRemoval = nil }
78 } message: {
79 // The command takes the label off every issue, not just the set.
80 Text(model.owner.isOrg
81 ? "This takes the label off every issue in every repository under the org."
82 : "This takes the label off every issue that carries it.")
83 }
84 }
85}
86
87private struct LabelRow: View {
88 let label: IssueLabel
89 let color: Color
90 let manageable: Bool
91 let working: Bool
92 let recolour: (String) -> Void
93 let remove: () -> Void
94
95 var body: some View {
96 HStack(spacing: 8) {
97 GBChip(label.name, color)
98 Text(label.issues == 1 ? "1 issue" : "\(label.issues) issues")
99 .font(.gbSans(.caption))
100 .foregroundStyle(.secondary)
101 Spacer()
102 // An org label is managed with `org label`; the repository's
103 // own commands refuse it, so it gets the mark and no menu.
104 if !manageable {
105 GBChip("org", .secondary)
106 } else {
107 Menu {
108 ForEach(LabelPalette.palette, id: \.self) { hex in
109 Button(hex) { recolour(String(hex.dropFirst())) }
110 }
111 Button("Clear colour") { recolour("") }
112 Button("Remove", role: .destructive) { remove() }
113 } label: {
114 Image(systemName: "ellipsis.circle")
115 }
116 .disabled(working)
117 }
118 }
119 }
120}