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