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? @State private var renamingOrg = false @State private var newOrgName = "" @State private var deletingOrg = false @State private var deleteConfirm = "" /// Set once the organization is deleted: nothing on this screen applies. @State private var deleted = false /// A delete refused, shown where the button is rather than in the /// banner at the top of a list scrolled past it. @State private var refusal: String? init(client: GitbayClient, org: String) { _model = State(initialValue: OrgViewModel(client: client, orgName: org)) } var body: some View { List { if deleted { Section { Label("\(model.orgName) is deleted", systemImage: "trash") .font(.gbSans(.subheadline)) .accessibilityIdentifier("org-deleted") } } else if let loaded = model.state.value { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } membersSection(loaded.members) teamsSection(loaded.teams) issuesSection deleteSection } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle(model.orgName) .navigationBarTitleDisplayMode(.inline) .toolbar { if !deleted { ToolbarItem(placement: .topBarTrailing) { Button { newOrgName = "" renamingOrg = true } label: { Image(systemName: "pencil") } .accessibilityLabel("Rename Organization") .disabled(model.working) .accessibilityIdentifier("org-rename-button") } } } .task { await model.load() } .refreshable { guard !deleted else { return } await model.load() } .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) { Button("OK", role: .cancel) {} } message: { Text(refusal ?? "") } .alert("Delete \(model.orgName)", isPresented: $deletingOrg) { TextField("type \(model.orgName) to confirm", text: $deleteConfirm) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Delete", role: .destructive) { Task { if await model.delete() { deleted = true } else { refusal = model.actionError } } } .disabled(deleteConfirm != model.orgName) Button("Cancel", role: .cancel) {} } message: { 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.") } .alert("Rename \(model.orgName)", isPresented: $renamingOrg) { TextField(model.orgName, text: $newOrgName) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button("Rename") { Task { await model.rename(to: newOrgName) } } .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty) Button("Cancel", role: .cancel) {} } message: { Text("Clone URLs for every repository in this organization change to the new name.") } .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") } .accessibilityLabel("Add member") .disabled(newMember.trimmingCharacters(in: .whitespaces).isEmpty || model.working) } } } private var deleteSection: some View { Section { Button(role: .destructive) { deleteConfirm = "" deletingOrg = true } label: { Label("Delete organization", systemImage: "trash") .font(.gbSans(.subheadline)) } .disabled(model.working) .accessibilityIdentifier("org-delete-button") } footer: { Text("Only an organization that owns no repositories can be deleted.") } } /// Labels and milestones every repository under the org sees /// (v1.19.0), managed here with `org label` and `org milestone`. private var issuesSection: some View { Section { NavigationLink(value: OrgRoute.labels(model.orgName)) { Label("Labels", systemImage: "tag") .font(.gbSans(.subheadline)) } NavigationLink(value: OrgRoute.milestones(model.orgName)) { Label("Milestones", systemImage: "flag") .font(.gbSans(.subheadline)) } } header: { Text("Issues") } footer: { Text("Every repository in the organization sees these; a same-named repository label or milestone folds into the org's.") } } 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") } .accessibilityLabel("Create team") .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) case labels(String) case milestones(String) }