gitbay/Views/Orgs/OrgView.swift

v1.14.1
gitbay-ios/gitbay/Views/Orgs/OrgView.swift history · blame · raw

257 lines · 10071 bytes

  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}