gitbay/Views/Issues/LabelListView.swift
122 lines · 4680 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 .accessibilityLabel("Add label")
41 .disabled(
42 newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working
43 )
44 .accessibilityIdentifier("label-create-submit")
45 }
46 }
47
48 ForEach(model.state.value ?? []) { label in
49 // A repository sees its org's labels but cannot change
50 // them; the org's own screen can.
51 LabelRow(label: label, color: colors.color(label.name),
52 manageable: !label.org || model.owner.isOrg, working: model.working) {
53 hex in
54 Task { await model.setLabel(label.name, color: hex) }
55 } remove: {
56 pendingRemoval = label
57 }
58 }
59 }
60 .overlay { LoadStateOverlay(state: model.state) }
61 .navigationTitle("Labels")
62 .navigationBarTitleDisplayMode(.inline)
63 .task { await model.load() }
64 .refreshable { await model.load() }
65 .confirmationDialog(
66 "Remove \(pendingRemoval?.name ?? "")?",
67 isPresented: .init(
68 get: { pendingRemoval != nil },
69 set: { if !$0 { pendingRemoval = nil } }
70 ),
71 titleVisibility: .visible
72 ) {
73 Button("Remove", role: .destructive) {
74 guard let label = pendingRemoval else { return }
75 pendingRemoval = nil
76 Task { await model.remove(label.name) }
77 }
78 Button("Cancel", role: .cancel) { pendingRemoval = nil }
79 } message: {
80 // The command takes the label off every issue, not just the set.
81 Text(model.owner.isOrg
82 ? "This takes the label off every issue in every repository under the org."
83 : "This takes the label off every issue that carries it.")
84 }
85 }
86}
87
88private struct LabelRow: View {
89 let label: IssueLabel
90 let color: Color
91 let manageable: Bool
92 let working: Bool
93 let recolour: (String) -> Void
94 let remove: () -> Void
95
96 var body: some View {
97 HStack(spacing: 8) {
98 GBChip(label.name, color)
99 Text(label.issues == 1 ? "1 issue" : "\(label.issues) issues")
100 .font(.gbSans(.caption))
101 .foregroundStyle(.secondary)
102 Spacer()
103 // An org label is managed with `org label`; the repository's
104 // own commands refuse it, so it gets the mark and no menu.
105 if !manageable {
106 GBChip("org", .secondary)
107 } else {
108 Menu {
109 ForEach(LabelPalette.palette, id: \.self) { hex in
110 Button(hex) { recolour(String(hex.dropFirst())) }
111 }
112 Button("Clear colour") { recolour("") }
113 Button("Remove", role: .destructive) { remove() }
114 } label: {
115 Image(systemName: "ellipsis.circle")
116 }
117 .accessibilityLabel("Actions for \(label.name)")
118 .disabled(working)
119 }
120 }
121 }
122}