gitbay/Views/Orgs/OrgView.swift

178 lines · 6752 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
 16    init(client: GitbayClient, org: String) {
 17        _model = State(initialValue: OrgViewModel(client: client, orgName: org))
 18    }
 19
 20    var body: some View {
 21        List {
 22            if let loaded = model.state.value {
 23                if let error = model.actionError {
 24                    Section {
 25                        GBNotice(error, .gbWarn)
 26                    }
 27                }
 28                membersSection(loaded.members)
 29                teamsSection(loaded.teams)
 30            }
 31        }
 32        .overlay { LoadStateOverlay(state: model.state) }
 33        .navigationTitle(model.orgName)
 34        .navigationBarTitleDisplayMode(.inline)
 35        .toolbar {
 36            ToolbarItem(placement: .topBarTrailing) {
 37                Button {
 38                    newOrgName = ""
 39                    renamingOrg = true
 40                } label: {
 41                    Image(systemName: "pencil")
 42                }
 43                .disabled(model.working)
 44                .accessibilityIdentifier("org-rename-button")
 45            }
 46        }
 47        .task { await model.load() }
 48        .refreshable { await model.load() }
 49        .alert("Rename \(model.orgName)", isPresented: $renamingOrg) {
 50            TextField(model.orgName, text: $newOrgName)
 51                .autocorrectionDisabled()
 52                .textInputAutocapitalization(.never)
 53            Button("Rename") {
 54                Task { await model.rename(to: newOrgName) }
 55            }
 56            .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty)
 57            Button("Cancel", role: .cancel) {}
 58        } message: {
 59            Text("Clone URLs for every repository in this organization change to the new name.")
 60        }
 61        .confirmationDialog(
 62            "Remove \(removingMember?.user ?? "") from \(model.orgName)?",
 63            isPresented: Binding(
 64                get: { removingMember != nil },
 65                set: { if !$0 { removingMember = nil } }
 66            )
 67        ) {
 68            Button("Remove", role: .destructive) {
 69                if let member = removingMember {
 70                    Task { await model.removeMember(member) }
 71                }
 72                removingMember = nil
 73            }
 74            Button("Cancel", role: .cancel) {}
 75        }
 76        .confirmationDialog(
 77            "Delete team \(deletingTeam ?? "")? Its repo grants go with it.",
 78            isPresented: Binding(
 79                get: { deletingTeam != nil },
 80                set: { if !$0 { deletingTeam = nil } }
 81            )
 82        ) {
 83            Button("Delete", role: .destructive) {
 84                if let team = deletingTeam {
 85                    Task { await model.deleteTeam(team) }
 86                }
 87                deletingTeam = nil
 88            }
 89            Button("Cancel", role: .cancel) {}
 90        }
 91    }
 92
 93    private func membersSection(_ members: [OrgMember]) -> some View {
 94        Section("Members") {
 95            ForEach(members) { member in
 96                HStack {
 97                    NavigationLink(value: RepoRoute.profile(member.user)) {
 98                        Text(member.user)
 99                            .font(.gbSans(.subheadline))
100                    }
101                    Spacer()
102                    Menu {
103                        Button("Admin") { Task { await model.setRole(member, role: "admin") } }
104                        Button("Member") { Task { await model.setRole(member, role: "member") } }
105                    } label: {
106                        GBChip(member.role, .secondary)
107                    }
108                    .disabled(model.working)
109                }
110                .swipeActions {
111                    Button("Remove", role: .destructive) {
112                        removingMember = member
113                    }
114                }
115            }
116            HStack {
117                TextField("Add user", text: $newMember)
118                    .autocorrectionDisabled()
119                    .textInputAutocapitalization(.never)
120                    .accessibilityIdentifier("org-add-member")
121                Picker("", selection: $newMemberRole) {
122                    Text("member").tag("member")
123                    Text("admin").tag("admin")
124                }
125                .labelsHidden()
126                .fixedSize()
127                Button {
128                    let user = newMember.trimmingCharacters(in: .whitespaces)
129                    newMember = ""
130                    Task { await model.addMember(user, role: newMemberRole) }
131                } label: {
132                    Image(systemName: "plus.circle.fill")
133                }
134                .disabled(newMember.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
135            }
136        }
137    }
138
139    private func teamsSection(_ teams: [String]) -> some View {
140        Section {
141            ForEach(teams, id: \.self) { team in
142                NavigationLink(value: OrgRoute.team(org: model.orgName, team: team)) {
143                    Label(team, systemImage: "person.3")
144                        .font(.gbSans(.subheadline))
145                }
146                .swipeActions {
147                    Button("Delete", role: .destructive) {
148                        deletingTeam = team
149                    }
150                }
151            }
152            HStack {
153                TextField("New team", text: $newTeam)
154                    .autocorrectionDisabled()
155                    .textInputAutocapitalization(.never)
156                    .accessibilityIdentifier("org-new-team")
157                Button {
158                    let team = newTeam.trimmingCharacters(in: .whitespaces)
159                    newTeam = ""
160                    Task { await model.createTeam(team) }
161                } label: {
162                    Image(systemName: "plus.circle.fill")
163                }
164                .disabled(newTeam.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
165                .accessibilityIdentifier("org-new-team-submit")
166            }
167        } header: {
168            Text("Teams")
169        } footer: {
170            Text("Plain membership already implies a role on every org repo; teams grant more.")
171        }
172    }
173}
174
175nonisolated enum OrgRoute: Hashable {
176    case org(String)
177    case team(org: String, team: String)
178}