import SwiftUI /// An organization's members and teams. Reads need membership — the /// server's refusal explains itself to outsiders. struct OrgView: View { @State private var model: OrgViewModel @State private var newMember = "" @State private var newMemberRole = "member" @State private var newTeam = "" @State private var removingMember: OrgMember? @State private var deletingTeam: String? init(client: GitbayClient, org: String) { _model = State(initialValue: OrgViewModel(client: client, orgName: org)) } var body: some View { List { if let loaded = model.state.value { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } membersSection(loaded.members) teamsSection(loaded.teams) } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle(model.orgName) .navigationBarTitleDisplayMode(.inline) .task { await model.load() } .refreshable { await model.load() } .confirmationDialog( "Remove \(removingMember?.user ?? "") from \(model.orgName)?", isPresented: Binding( get: { removingMember != nil }, set: { if !$0 { removingMember = nil } } ) ) { Button("Remove", role: .destructive) { if let member = removingMember { Task { await model.removeMember(member) } } removingMember = nil } Button("Cancel", role: .cancel) {} } .confirmationDialog( "Delete team \(deletingTeam ?? "")? Its repo grants go with it.", isPresented: Binding( get: { deletingTeam != nil }, set: { if !$0 { deletingTeam = nil } } ) ) { Button("Delete", role: .destructive) { if let team = deletingTeam { Task { await model.deleteTeam(team) } } deletingTeam = nil } Button("Cancel", role: .cancel) {} } } private func membersSection(_ members: [OrgMember]) -> some View { Section("Members") { ForEach(members) { member in HStack { NavigationLink(value: RepoRoute.profile(member.user)) { Text(member.user) .font(.gbSans(.subheadline)) } Spacer() Menu { Button("Admin") { Task { await model.setRole(member, role: "admin") } } Button("Member") { Task { await model.setRole(member, role: "member") } } } label: { GBChip(member.role, .secondary) } .disabled(model.working) } .swipeActions { Button("Remove", role: .destructive) { removingMember = member } } } HStack { TextField("Add user", text: $newMember) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("org-add-member") Picker("", selection: $newMemberRole) { Text("member").tag("member") Text("admin").tag("admin") } .labelsHidden() .fixedSize() Button { let user = newMember.trimmingCharacters(in: .whitespaces) newMember = "" Task { await model.addMember(user, role: newMemberRole) } } label: { Image(systemName: "plus.circle.fill") } .disabled(newMember.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } } } private func teamsSection(_ teams: [String]) -> some View { Section { ForEach(teams, id: \.self) { team in NavigationLink(value: OrgRoute.team(org: model.orgName, team: team)) { Label(team, systemImage: "person.3") .font(.gbSans(.subheadline)) } .swipeActions { Button("Delete", role: .destructive) { deletingTeam = team } } } HStack { TextField("New team", text: $newTeam) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("org-new-team") Button { let team = newTeam.trimmingCharacters(in: .whitespaces) newTeam = "" Task { await model.createTeam(team) } } label: { Image(systemName: "plus.circle.fill") } .disabled(newTeam.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("org-new-team-submit") } } header: { Text("Teams") } footer: { Text("Plain membership already implies a role on every org repo; teams grant more.") } } } nonisolated enum OrgRoute: Hashable { case org(String) case team(org: String, team: String) }