gitbay/Views/Orgs/OrgView.swift

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