gitbay/Views/Orgs/OrgView.swift
257 lines · 10071 bytes
3 symbols in this file
1import SwiftUI
2
3/// An organization's members and teams. Reads need membership — the
4/// server's refusal explains itself to outsiders.
5struct OrgView: View {
6
7 @State private var model: OrgViewModel
8 @State private var newMember = ""
9 @State private var newMemberRole = "member"
10 @State private var newTeam = ""
11 @State private var removingMember: OrgMember?
12 @State private var deletingTeam: String?
13 @State private var renamingOrg = false
14 @State private var newOrgName = ""
15 @State private var deletingOrg = false
16 @State private var deleteConfirm = ""
17 /// Set once the organization is deleted: nothing on this screen applies.
18 @State private var deleted = false
19 /// A delete refused, shown where the button is rather than in the
20 /// banner at the top of a list scrolled past it.
21 @State private var refusal: String?
22
23 init(client: GitbayClient, org: String) {
24 _model = State(initialValue: OrgViewModel(client: client, orgName: org))
25 }
26
27 var body: some View {
28 List {
29 if deleted {
30 Section {
31 Label("\(model.orgName) is deleted", systemImage: "trash")
32 .font(.gbSans(.subheadline))
33 .accessibilityIdentifier("org-deleted")
34 }
35 } else if let loaded = model.state.value {
36 if let error = model.actionError {
37 Section {
38 GBNotice(error, .gbWarn)
39 }
40 }
41 membersSection(loaded.members)
42 teamsSection(loaded.teams)
43 issuesSection
44 deleteSection
45 }
46 }
47 .overlay { LoadStateOverlay(state: model.state) }
48 .navigationTitle(model.orgName)
49 .navigationBarTitleDisplayMode(.inline)
50 .toolbar {
51 if !deleted {
52 ToolbarItem(placement: .topBarTrailing) {
53 Button {
54 newOrgName = ""
55 renamingOrg = true
56 } label: {
57 Image(systemName: "pencil")
58 }
59 .accessibilityLabel("Rename Organization")
60 .disabled(model.working)
61 .accessibilityIdentifier("org-rename-button")
62 }
63 }
64 }
65 .task { await model.load() }
66 .refreshable {
67 guard !deleted else { return }
68 await model.load()
69 }
70 .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) {
71 Button("OK", role: .cancel) {}
72 } message: {
73 Text(refusal ?? "")
74 }
75 .alert("Delete \(model.orgName)", isPresented: $deletingOrg) {
76 TextField("type \(model.orgName) to confirm", text: $deleteConfirm)
77 .autocorrectionDisabled()
78 .textInputAutocapitalization(.never)
79 Button("Delete", role: .destructive) {
80 Task {
81 if await model.delete() { deleted = true } else { refusal = model.actionError }
82 }
83 }
84 .disabled(deleteConfirm != model.orgName)
85 Button("Cancel", role: .cancel) {}
86 } message: {
87 Text("This deletes the organization with its teams, labels and milestones, and cannot be undone. It must own no repositories. Type \(model.orgName) to confirm.")
88 }
89 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) {
90 TextField(model.orgName, text: $newOrgName)
91 .autocorrectionDisabled()
92 .textInputAutocapitalization(.never)
93 Button("Rename") {
94 Task { await model.rename(to: newOrgName) }
95 }
96 .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
97 Button("Cancel", role: .cancel) {}
98 } message: {
99 Text("Clone URLs for every repository in this organization change to the new name.")
100 }
101 .confirmationDialog(
102 "Remove \(removingMember?.user ?? "") from \(model.orgName)?",
103 isPresented: Binding(
104 get: { removingMember != nil },
105 set: { if !$0 { removingMember = nil } }
106 )
107 ) {
108 Button("Remove", role: .destructive) {
109 if let member = removingMember {
110 Task { await model.removeMember(member) }
111 }
112 removingMember = nil
113 }
114 Button("Cancel", role: .cancel) {}
115 }
116 .confirmationDialog(
117 "Delete team \(deletingTeam ?? "")? Its repo grants go with it.",
118 isPresented: Binding(
119 get: { deletingTeam != nil },
120 set: { if !$0 { deletingTeam = nil } }
121 )
122 ) {
123 Button("Delete", role: .destructive) {
124 if let team = deletingTeam {
125 Task { await model.deleteTeam(team) }
126 }
127 deletingTeam = nil
128 }
129 Button("Cancel", role: .cancel) {}
130 }
131 }
132
133 private func membersSection(_ members: [OrgMember]) -> some View {
134 Section("Members") {
135 ForEach(members) { member in
136 HStack {
137 NavigationLink(value: RepoRoute.profile(member.user)) {
138 Text(member.user)
139 .font(.gbSans(.subheadline))
140 }
141 Spacer()
142 Menu {
143 Button("Admin") { Task { await model.setRole(member, role: "admin") } }
144 Button("Member") { Task { await model.setRole(member, role: "member") } }
145 } label: {
146 GBChip(member.role, .secondary)
147 }
148 .disabled(model.working)
149 }
150 .swipeActions {
151 Button("Remove", role: .destructive) {
152 removingMember = member
153 }
154 }
155 }
156 HStack {
157 TextField("Add user", text: $newMember)
158 .autocorrectionDisabled()
159 .textInputAutocapitalization(.never)
160 .accessibilityIdentifier("org-add-member")
161 Picker("", selection: $newMemberRole) {
162 Text("member").tag("member")
163 Text("admin").tag("admin")
164 }
165 .labelsHidden()
166 .fixedSize()
167 Button {
168 let user = newMember.trimmingCharacters(in: .whitespaces)
169 newMember = ""
170 Task { await model.addMember(user, role: newMemberRole) }
171 } label: {
172 Image(systemName: "plus.circle.fill")
173 }
174 .accessibilityLabel("Add member")
175 .disabled(newMember.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
176 }
177 }
178 }
179
180 private var deleteSection: some View {
181 Section {
182 Button(role: .destructive) {
183 deleteConfirm = ""
184 deletingOrg = true
185 } label: {
186 Label("Delete organization", systemImage: "trash")
187 .font(.gbSans(.subheadline))
188 }
189 .disabled(model.working)
190 .accessibilityIdentifier("org-delete-button")
191 } footer: {
192 Text("Only an organization that owns no repositories can be deleted.")
193 }
194 }
195
196 /// Labels and milestones every repository under the org sees
197 /// (v1.19.0), managed here with `org label` and `org milestone`.
198 private var issuesSection: some View {
199 Section {
200 NavigationLink(value: OrgRoute.labels(model.orgName)) {
201 Label("Labels", systemImage: "tag")
202 .font(.gbSans(.subheadline))
203 }
204 NavigationLink(value: OrgRoute.milestones(model.orgName)) {
205 Label("Milestones", systemImage: "flag")
206 .font(.gbSans(.subheadline))
207 }
208 } header: {
209 Text("Issues")
210 } footer: {
211 Text("Every repository in the organization sees these; a same-named repository label or milestone folds into the org's.")
212 }
213 }
214
215 private func teamsSection(_ teams: [String]) -> some View {
216 Section {
217 ForEach(teams, id: \.self) { team in
218 NavigationLink(value: OrgRoute.team(org: model.orgName, team: team)) {
219 Label(team, systemImage: "person.3")
220 .font(.gbSans(.subheadline))
221 }
222 .swipeActions {
223 Button("Delete", role: .destructive) {
224 deletingTeam = team
225 }
226 }
227 }
228 HStack {
229 TextField("New team", text: $newTeam)
230 .autocorrectionDisabled()
231 .textInputAutocapitalization(.never)
232 .accessibilityIdentifier("org-new-team")
233 Button {
234 let team = newTeam.trimmingCharacters(in: .whitespaces)
235 newTeam = ""
236 Task { await model.createTeam(team) }
237 } label: {
238 Image(systemName: "plus.circle.fill")
239 }
240 .accessibilityLabel("Create team")
241 .disabled(newTeam.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
242 .accessibilityIdentifier("org-new-team-submit")
243 }
244 } header: {
245 Text("Teams")
246 } footer: {
247 Text("Plain membership already implies a role on every org repo; teams grant more.")
248 }
249 }
250}
251
252nonisolated enum OrgRoute: Hashable {
253 case org(String)
254 case team(org: String, team: String)
255 case labels(String)
256 case milestones(String)
257}