gitbay/Views/Orgs/OrgView.swift

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

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