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