Skip to content

Commit bfe9eeb

Browse files
authored
Merge UserDataProvider and UserManagementActionDispatcher into one type (#23792)
* Merge UserDataProvider and UserManagementActionDispatcher into one type * Cache current user info in UserService * Minor change to function declarations * Add unit tests for UserService * Use AsyncStream to publish updates instead of Combine publisher * Remove some whitespaces * Terminate users updates stream upon deallocating * Use `onAppear` instead of `Task` to avoid duplicated calls
1 parent dfdc8de commit bfe9eeb

File tree

11 files changed

+345
-248
lines changed

11 files changed

+345
-248
lines changed

Modules/Sources/WordPressUI/Views/Users/Components/UserListItem.swift

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,16 @@ struct UserListItem: View {
99
var dynamicTypeSize
1010

1111
private let user: DisplayUser
12-
private let userProvider: UserDataProvider
13-
private let actionDispatcher: UserManagementActionDispatcher
12+
private let userService: UserServiceProtocol
1413

15-
init(user: DisplayUser, userProvider: UserDataProvider, actionDispatcher: UserManagementActionDispatcher) {
14+
init(user: DisplayUser, userService: UserServiceProtocol) {
1615
self.user = user
17-
self.userProvider = userProvider
18-
self.actionDispatcher = actionDispatcher
16+
self.userService = userService
1917
}
2018

2119
var body: some View {
2220
NavigationLink {
23-
UserDetailsView(user: user, userProvider: userProvider, actionDispatcher: actionDispatcher)
21+
UserDetailsView(user: user, userService: userService)
2422
} label: {
2523
HStack(alignment: .top) {
2624
if !dynamicTypeSize.isAccessibilitySize {
@@ -36,5 +34,5 @@ struct UserListItem: View {
3634
}
3735

3836
#Preview {
39-
UserListItem(user: DisplayUser.MockUser, userProvider: MockUserProvider(), actionDispatcher: UserManagementActionDispatcher())
37+
UserListItem(user: DisplayUser.MockUser, userService: MockUserProvider())
4038
}
Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,20 @@
11
import Foundation
2+
import Combine
23

3-
public protocol UserDataProvider {
4+
public protocol UserServiceProtocol: Actor {
5+
var users: [DisplayUser]? { get }
6+
nonisolated var usersUpdates: AsyncStream<[DisplayUser]> { get }
47

5-
typealias CachedUserListCallback = ([WordPressUI.DisplayUser]) async -> Void
8+
func fetchUsers() async throws -> [DisplayUser]
69

7-
func fetchCurrentUserCan(_ capability: String) async throws -> Bool
8-
func fetchUsers(cachedResults: CachedUserListCallback?) async throws -> [WordPressUI.DisplayUser]
10+
func isCurrentUserCapableOf(_ capability: String) async throws -> Bool
911

10-
func invalidateCaches() async throws
11-
}
12-
13-
/// Subclass this and register it with the SwiftUI `.environmentObject` method
14-
/// to perform user management actions.
15-
///
16-
/// The default implementation is set up for testing with SwiftUI Previews
17-
open class UserManagementActionDispatcher: ObservableObject {
18-
public init() {}
19-
20-
open func setNewPassword(id: Int32, newPassword: String) async throws {
21-
try await Task.sleep(for: .seconds(2))
22-
}
12+
func setNewPassword(id: Int32, newPassword: String) async throws
2313

24-
open func deleteUser(id: Int32, reassigningPostsTo userId: Int32) async throws {
25-
try await Task.sleep(for: .seconds(2))
26-
}
14+
func deleteUser(id: Int32, reassigningPostsTo newUserId: Int32) async throws
2715
}
2816

29-
package struct MockUserProvider: UserDataProvider {
17+
package actor MockUserProvider: UserServiceProtocol {
3018

3119
enum Scenario {
3220
case infinitLoading
@@ -36,29 +24,48 @@ package struct MockUserProvider: UserDataProvider {
3624

3725
var scenario: Scenario
3826

27+
package nonisolated let usersUpdates: AsyncStream<[DisplayUser]>
28+
private let usersUpdatesContinuation: AsyncStream<[DisplayUser]>.Continuation
29+
30+
package private(set) var users: [DisplayUser]? {
31+
didSet {
32+
if let users {
33+
usersUpdatesContinuation.yield(users)
34+
}
35+
}
36+
}
37+
3938
init(scenario: Scenario = .dummyData) {
4039
self.scenario = scenario
40+
(usersUpdates, usersUpdatesContinuation) = AsyncStream<[DisplayUser]>.makeStream()
4141
}
4242

43-
package func fetchUsers(cachedResults: CachedUserListCallback? = nil) async throws -> [WordPressUI.DisplayUser] {
43+
package func fetchUsers() async throws -> [DisplayUser] {
4444
switch scenario {
4545
case .infinitLoading:
46-
try await Task.sleep(for: .seconds(1 * 24 * 60 * 60))
46+
// Do nothing
47+
try await Task.sleep(for: .seconds(24 * 60 * 60))
4748
return []
4849
case .dummyData:
4950
let dummyDataUrl = URL(string: "https://my.api.mockaroo.com/users.json?key=067c9730")!
5051
let response = try await URLSession.shared.data(from: dummyDataUrl)
51-
return try JSONDecoder().decode([DisplayUser].self, from: response.0)
52+
let users = try JSONDecoder().decode([DisplayUser].self, from: response.0)
53+
self.users = users
54+
return users
5255
case .error:
5356
throw URLError(.timedOut)
5457
}
5558
}
5659

57-
package func fetchCurrentUserCan(_ capability: String) async throws -> Bool {
60+
package func isCurrentUserCapableOf(_ capability: String) async throws -> Bool {
5861
true
5962
}
6063

61-
package func invalidateCaches() async throws {
62-
// Do nothing
64+
package func setNewPassword(id: Int32, newPassword: String) async throws {
65+
// Not used in Preview
66+
}
67+
68+
package func deleteUser(id: Int32, reassigningPostsTo newUserId: Int32) async throws {
69+
// Not used in Preview
6370
}
6471
}

Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDeleteViewModel.swift

Lines changed: 28 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -13,92 +13,57 @@ public class UserDeleteViewModel: ObservableObject {
1313
private(set) var error: Error? = nil
1414

1515
@Published
16-
var otherUserId: Int32 = 0
16+
var selectedUser: DisplayUser? = nil
1717

1818
@Published
1919
private(set) var otherUsers: [DisplayUser] = []
2020

2121
@Published
2222
private(set) var deleteButtonIsDisabled: Bool = true
2323

24-
private let userProvider: UserDataProvider
25-
private let actionDispatcher: UserManagementActionDispatcher
24+
private let userService: UserServiceProtocol
2625
let user: DisplayUser
2726

28-
init(user: DisplayUser, userProvider: UserDataProvider, actionDispatcher: UserManagementActionDispatcher) {
27+
init(user: DisplayUser, userService: UserServiceProtocol) {
2928
self.user = user
30-
self.userProvider = userProvider
31-
self.actionDispatcher = actionDispatcher
32-
}
29+
self.userService = userService
3330

34-
func fetchOtherUsers() async {
35-
withAnimation {
36-
isFetchingOtherUsers = true
37-
deleteButtonIsDisabled = true
38-
}
31+
// Default `selectedUser` to be the first one in `otherUsers`.
32+
// Using Combine here because `didSet` observers don't work with `@Published` properties.
33+
//
34+
// The implementation is equivalent to `if selectedUser == nil { selectedUser = otherUsers.first }`
35+
$otherUsers.combineLatest($selectedUser)
36+
.filter { _, selectedUser in selectedUser == nil }
37+
.map { others, _ in others.first }
38+
.assign(to: &$selectedUser)
3939

40-
do {
41-
let otherUsers = try await userProvider.fetchUsers { self.didReceiveUsers($0) }
40+
}
4241

43-
self.didReceiveUsers(otherUsers)
44-
} catch {
45-
withAnimation {
46-
self.error = error
47-
deleteButtonIsDisabled = true
48-
}
49-
}
42+
func fetchOtherUsers() async {
43+
isFetchingOtherUsers = true
44+
deleteButtonIsDisabled = true
5045

51-
withAnimation {
46+
defer {
5247
isFetchingOtherUsers = false
48+
deleteButtonIsDisabled = otherUsers.isEmpty
5349
}
54-
}
55-
56-
func didReceiveUsers(_ users: [DisplayUser]) {
57-
withAnimation {
58-
if otherUserId == 0 {
59-
otherUserId = otherUsers.first?.id ?? 0
60-
}
6150

62-
otherUsers = users
51+
do {
52+
let users = try await userService.fetchUsers()
53+
self.otherUsers = users
6354
.filter { $0.id != self.user.id } // Don't allow re-assigning to yourself
6455
.sorted(using: KeyPathComparator(\.username))
65-
error = nil
66-
deleteButtonIsDisabled = false
67-
isFetchingOtherUsers = false
56+
} catch {
57+
self.error = error
6858
}
6959
}
7060

71-
func didTapDeleteUser(callback: @escaping () -> Void) {
72-
debugPrint("Deleting \(user.username) and re-assigning their content to \(otherUserId)")
61+
func deleteUser() async throws {
62+
guard let otherUserId = selectedUser?.id, otherUserId != user.id else { return }
7363

74-
withAnimation {
75-
error = nil
76-
}
64+
isDeletingUser = true
65+
defer { isDeletingUser = false }
7766

78-
Task {
79-
await MainActor.run {
80-
withAnimation {
81-
isDeletingUser = true
82-
}
83-
}
84-
85-
do {
86-
try await actionDispatcher.deleteUser(id: user.id, reassigningPostsTo: otherUserId)
87-
} catch {
88-
debugPrint(error.localizedDescription)
89-
await MainActor.run {
90-
withAnimation {
91-
self.error = error
92-
}
93-
}
94-
}
95-
96-
await MainActor.run {
97-
withAnimation {
98-
isDeletingUser = false
99-
callback()
100-
}
101-
}
102-
}
67+
try await userService.deleteUser(id: user.id, reassigningPostsTo: otherUserId)
10368
}
10469
}

Modules/Sources/WordPressUI/Views/Users/ViewModel/UserDetailViewModel.swift

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import SwiftUI
22

33
@MainActor
44
class UserDetailViewModel: ObservableObject {
5-
private let userProvider: UserDataProvider
5+
private let userService: UserServiceProtocol
66

77
@Published
88
private(set) var currentUserCanModifyUsers: Bool = false
@@ -13,30 +13,20 @@ class UserDetailViewModel: ObservableObject {
1313
@Published
1414
private(set) var error: Error? = nil
1515

16-
init(userProvider: UserDataProvider) {
17-
self.userProvider = userProvider
16+
init(userService: UserServiceProtocol) {
17+
self.userService = userService
1818
}
1919

2020
func loadCurrentUserRole() async {
21-
withAnimation {
22-
isLoadingCurrentUser = true
23-
}
21+
error = nil
2422

25-
do {
26-
let hasPermissions = try await userProvider.fetchCurrentUserCan("edit_users")
27-
error = nil
23+
isLoadingCurrentUser = true
24+
defer { isLoadingCurrentUser = false}
2825

29-
withAnimation {
30-
currentUserCanModifyUsers = hasPermissions
31-
}
26+
do {
27+
currentUserCanModifyUsers = try await userService.isCurrentUserCapableOf("edit_users")
3228
} catch {
33-
withAnimation {
34-
self.error = error
35-
}
36-
}
37-
38-
withAnimation {
39-
isLoadingCurrentUser = false
29+
self.error = error
4030
}
4131
}
4232
}

Modules/Sources/WordPressUI/Views/Users/ViewModel/UserListViewModel.swift

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SwiftUI
2+
import Combine
23
import WordPressShared
34

45
@MainActor
@@ -11,9 +12,13 @@ class UserListViewModel: ObservableObject {
1112
}
1213

1314
/// The initial set of users fetched by `fetchItems`
14-
private var users: [DisplayUser] = []
15-
private let userProvider: UserDataProvider
16-
15+
private var users: [DisplayUser] = [] {
16+
didSet {
17+
sortedUsers = self.sortUsers(users)
18+
}
19+
}
20+
private var updateUsersTask: Task<Void, Never>?
21+
private let userService: UserServiceProtocol
1722
private var initialLoad = false
1823

1924
@Published
@@ -37,43 +42,41 @@ class UserListViewModel: ObservableObject {
3742
}
3843
}
3944

40-
init(userProvider: UserDataProvider) {
41-
self.userProvider = userProvider
45+
init(userService: UserServiceProtocol) {
46+
self.userService = userService
47+
}
48+
49+
deinit {
50+
updateUsersTask?.cancel()
4251
}
4352

4453
func onAppear() async {
54+
if updateUsersTask == nil {
55+
updateUsersTask = Task { @MainActor [weak self, usersUpdates = userService.usersUpdates] in
56+
for await users in usersUpdates {
57+
guard let self else { break }
58+
59+
self.users = users
60+
}
61+
}
62+
}
63+
4564
if !initialLoad {
4665
initialLoad = true
4766
await fetchItems()
4867
}
4968
}
5069

51-
func fetchItems() async {
52-
withAnimation {
53-
isLoadingItems = true
54-
}
70+
private func fetchItems() async {
71+
isLoadingItems = true
72+
defer { isLoadingItems = false }
5573

56-
do {
57-
let users = try await userProvider.fetchUsers { cachedResults in
58-
self.setUsers(cachedResults)
59-
}
60-
setUsers(users)
61-
} catch {
62-
self.error = error
63-
isLoadingItems = false
64-
}
74+
_ = try? await userService.fetchUsers()
6575
}
6676

6777
@Sendable
6878
func refreshItems() async {
69-
do {
70-
let users = try await userProvider.fetchUsers { cachedResults in
71-
self.setUsers(cachedResults)
72-
}
73-
setUsers(users)
74-
} catch {
75-
// Do nothing for now – this should probably show a "Toast" notification or something
76-
}
79+
_ = try? await userService.fetchUsers()
7780
}
7881

7982
func setUsers(_ newValue: [DisplayUser]) {

0 commit comments

Comments
 (0)