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