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