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