From 08454d98ce1c5daab94cc30014d6c4ede993784b Mon Sep 17 00:00:00 2001
From: Dariusz Rybicki <dariusz@elixxir.io>
Date: Fri, 21 Oct 2022 12:43:34 +0200
Subject: [PATCH] Migrate UserSearchFeature to ReducerProtocol

---
 .../UserSearchComponent.swift                 | 146 +++++++++++++++
 .../UserSearchFeature/UserSearchFeature.swift | 168 ------------------
 .../UserSearchFeature/UserSearchView.swift    |  25 ++-
 ...s.swift => UserSearchComponentTests.swift} |  38 ++--
 4 files changed, 175 insertions(+), 202 deletions(-)
 create mode 100644 Examples/xx-messenger/Sources/UserSearchFeature/UserSearchComponent.swift
 delete mode 100644 Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift
 rename Examples/xx-messenger/Tests/UserSearchFeatureTests/{UserSearchFeatureTests.swift => UserSearchComponentTests.swift} (81%)

diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchComponent.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchComponent.swift
new file mode 100644
index 00000000..c274d15b
--- /dev/null
+++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchComponent.swift
@@ -0,0 +1,146 @@
+import ComposableArchitecture
+import ComposablePresentation
+import ContactFeature
+import Foundation
+import XCTestDynamicOverlay
+import XXClient
+import XXMessengerClient
+
+public struct UserSearchComponent: ReducerProtocol {
+  public struct State: Equatable {
+    public enum Field: String, Hashable {
+      case username
+      case email
+      case phone
+    }
+
+    public struct Result: Equatable, Identifiable {
+      public init(
+        id: Data,
+        xxContact: XXClient.Contact,
+        username: String? = nil,
+        email: String? = nil,
+        phone: String? = nil
+      ) {
+        self.id = id
+        self.xxContact = xxContact
+        self.username = username
+        self.email = email
+        self.phone = phone
+      }
+
+      public var id: Data
+      public var xxContact: XXClient.Contact
+      public var username: String?
+      public var email: String?
+      public var phone: String?
+
+      public var hasFacts: Bool {
+        username != nil || email != nil || phone != nil
+      }
+    }
+
+    public init(
+      focusedField: Field? = nil,
+      query: MessengerSearchContacts.Query = .init(),
+      isSearching: Bool = false,
+      failure: String? = nil,
+      results: IdentifiedArrayOf<Result> = [],
+      contact: ContactComponent.State? = nil
+    ) {
+      self.focusedField = focusedField
+      self.query = query
+      self.isSearching = isSearching
+      self.failure = failure
+      self.results = results
+      self.contact = contact
+    }
+
+    @BindableState public var focusedField: Field?
+    @BindableState public var query: MessengerSearchContacts.Query
+    public var isSearching: Bool
+    public var failure: String?
+    public var results: IdentifiedArrayOf<Result>
+    public var contact: ContactComponent.State?
+  }
+
+  public enum Action: Equatable, BindableAction {
+    case searchTapped
+    case didFail(String)
+    case didSucceed([Contact])
+    case didDismissContact
+    case resultTapped(id: Data)
+    case binding(BindingAction<State>)
+    case contact(ContactComponent.Action)
+  }
+
+  public init() {}
+
+  @Dependency(\.app.messenger) var messenger: Messenger
+  @Dependency(\.app.mainQueue) var mainQueue: AnySchedulerOf<DispatchQueue>
+  @Dependency(\.app.bgQueue) var bgQueue: AnySchedulerOf<DispatchQueue>
+
+  public var body: some ReducerProtocol<State, Action> {
+    BindingReducer()
+    Reduce { state, action in
+      switch action {
+      case .searchTapped:
+        state.focusedField = nil
+        state.isSearching = true
+        state.results = []
+        state.failure = nil
+        return .result { [query = state.query] in
+          do {
+            return .success(.didSucceed(try messenger.searchContacts(query: query)))
+          } catch {
+            return .success(.didFail(error.localizedDescription))
+          }
+        }
+        .subscribe(on: bgQueue)
+        .receive(on: mainQueue)
+        .eraseToEffect()
+
+      case .didSucceed(let contacts):
+        state.isSearching = false
+        state.failure = nil
+        state.results = IdentifiedArray(uniqueElements: contacts.compactMap { contact in
+          guard let id = try? contact.getId() else { return nil }
+          return State.Result(
+            id: id,
+            xxContact: contact,
+            username: try? contact.getFact(.username)?.value,
+            email: try? contact.getFact(.email)?.value,
+            phone: try? contact.getFact(.phone)?.value
+          )
+        })
+        return .none
+
+      case .didFail(let failure):
+        state.isSearching = false
+        state.failure = failure
+        state.results = []
+        return .none
+
+      case .didDismissContact:
+        state.contact = nil
+        return .none
+
+      case .resultTapped(let id):
+        state.contact = ContactComponent.State(
+          id: id,
+          xxContact: state.results[id: id]?.xxContact
+        )
+        return .none
+
+      case .binding(_), .contact(_):
+        return .none
+      }
+    }
+    .presenting(
+      state: .keyPath(\.contact),
+      id: .keyPath(\.?.id),
+      action: /Action.contact,
+      presented: { ContactComponent() }
+    )
+  }
+}
diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift
deleted file mode 100644
index f39353a7..00000000
--- a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchFeature.swift
+++ /dev/null
@@ -1,168 +0,0 @@
-import ComposableArchitecture
-import ComposablePresentation
-import ContactFeature
-import Foundation
-import XCTestDynamicOverlay
-import XXClient
-import XXMessengerClient
-
-public struct UserSearchState: Equatable {
-  public enum Field: String, Hashable {
-    case username
-    case email
-    case phone
-  }
-
-  public struct Result: Equatable, Identifiable {
-    public init(
-      id: Data,
-      xxContact: XXClient.Contact,
-      username: String? = nil,
-      email: String? = nil,
-      phone: String? = nil
-    ) {
-      self.id = id
-      self.xxContact = xxContact
-      self.username = username
-      self.email = email
-      self.phone = phone
-    }
-
-    public var id: Data
-    public var xxContact: XXClient.Contact
-    public var username: String?
-    public var email: String?
-    public var phone: String?
-
-    public var hasFacts: Bool {
-      username != nil || email != nil || phone != nil
-    }
-  }
-
-  public init(
-    focusedField: Field? = nil,
-    query: MessengerSearchContacts.Query = .init(),
-    isSearching: Bool = false,
-    failure: String? = nil,
-    results: IdentifiedArrayOf<Result> = [],
-    contact: ContactState? = nil
-  ) {
-    self.focusedField = focusedField
-    self.query = query
-    self.isSearching = isSearching
-    self.failure = failure
-    self.results = results
-    self.contact = contact
-  }
-
-  @BindableState public var focusedField: Field?
-  @BindableState public var query: MessengerSearchContacts.Query
-  public var isSearching: Bool
-  public var failure: String?
-  public var results: IdentifiedArrayOf<Result>
-  public var contact: ContactState?
-}
-
-public enum UserSearchAction: Equatable, BindableAction {
-  case searchTapped
-  case didFail(String)
-  case didSucceed([Contact])
-  case didDismissContact
-  case resultTapped(id: Data)
-  case binding(BindingAction<UserSearchState>)
-  case contact(ContactAction)
-}
-
-public struct UserSearchEnvironment {
-  public init(
-    messenger: Messenger,
-    mainQueue: AnySchedulerOf<DispatchQueue>,
-    bgQueue: AnySchedulerOf<DispatchQueue>,
-    contact: @escaping () -> ContactEnvironment
-  ) {
-    self.messenger = messenger
-    self.mainQueue = mainQueue
-    self.bgQueue = bgQueue
-    self.contact = contact
-  }
-
-  public var messenger: Messenger
-  public var mainQueue: AnySchedulerOf<DispatchQueue>
-  public var bgQueue: AnySchedulerOf<DispatchQueue>
-  public var contact: () -> ContactEnvironment
-}
-
-#if DEBUG
-extension UserSearchEnvironment {
-  public static let unimplemented = UserSearchEnvironment(
-    messenger: .unimplemented,
-    mainQueue: .unimplemented,
-    bgQueue: .unimplemented,
-    contact: { .unimplemented }
-  )
-}
-#endif
-
-public let userSearchReducer = Reducer<UserSearchState, UserSearchAction, UserSearchEnvironment>
-{ state, action, env in
-  switch action {
-  case .searchTapped:
-    state.focusedField = nil
-    state.isSearching = true
-    state.results = []
-    state.failure = nil
-    return .result { [query = state.query] in
-      do {
-        return .success(.didSucceed(try env.messenger.searchContacts(query: query)))
-      } catch {
-        return .success(.didFail(error.localizedDescription))
-      }
-    }
-    .subscribe(on: env.bgQueue)
-    .receive(on: env.mainQueue)
-    .eraseToEffect()
-
-  case .didSucceed(let contacts):
-    state.isSearching = false
-    state.failure = nil
-    state.results = IdentifiedArray(uniqueElements: contacts.compactMap { contact in
-      guard let id = try? contact.getId() else { return nil }
-      return UserSearchState.Result(
-        id: id,
-        xxContact: contact,
-        username: try? contact.getFact(.username)?.value,
-        email: try? contact.getFact(.email)?.value,
-        phone: try? contact.getFact(.phone)?.value
-      )
-    })
-    return .none
-
-  case .didFail(let failure):
-    state.isSearching = false
-    state.failure = failure
-    state.results = []
-    return .none
-
-  case .didDismissContact:
-    state.contact = nil
-    return .none
-
-  case .resultTapped(let id):
-    state.contact = ContactState(
-      id: id,
-      xxContact: state.results[id: id]?.xxContact
-    )
-    return .none
-
-  case .binding(_), .contact(_):
-    return .none
-  }
-}
-.binding()
-.presenting(
-  contactReducer,
-  state: .keyPath(\.contact),
-  id: .keyPath(\.?.id),
-  action: /UserSearchAction.contact,
-  environment: { $0.contact() }
-)
diff --git a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift
index 328ff98c..ea5f2ad7 100644
--- a/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift
+++ b/Examples/xx-messenger/Sources/UserSearchFeature/UserSearchView.swift
@@ -5,21 +5,21 @@ import SwiftUI
 import XXMessengerClient
 
 public struct UserSearchView: View {
-  public init(store: Store<UserSearchState, UserSearchAction>) {
+  public init(store: StoreOf<UserSearchComponent>) {
     self.store = store
   }
 
-  let store: Store<UserSearchState, UserSearchAction>
-  @FocusState var focusedField: UserSearchState.Field?
+  let store: StoreOf<UserSearchComponent>
+  @FocusState var focusedField: UserSearchComponent.State.Field?
 
   struct ViewState: Equatable {
-    var focusedField: UserSearchState.Field?
+    var focusedField: UserSearchComponent.State.Field?
     var query: MessengerSearchContacts.Query
     var isSearching: Bool
     var failure: String?
-    var results: IdentifiedArrayOf<UserSearchState.Result>
+    var results: IdentifiedArrayOf<UserSearchComponent.State.Result>
 
-    init(state: UserSearchState) {
+    init(state: UserSearchComponent.State) {
       focusedField = state.focusedField
       query = state.query
       isSearching = state.isSearching
@@ -35,7 +35,7 @@ public struct UserSearchView: View {
           TextField(
             text: viewStore.binding(
               get: { $0.query.username ?? "" },
-              send: { UserSearchAction.set(\.$query.username, $0.isEmpty ? nil : $0) }
+              send: { UserSearchComponent.Action.set(\.$query.username, $0.isEmpty ? nil : $0) }
             ),
             prompt: Text("Enter username"),
             label: { Text("Username") }
@@ -45,7 +45,7 @@ public struct UserSearchView: View {
           TextField(
             text: viewStore.binding(
               get: { $0.query.email ?? "" },
-              send: { UserSearchAction.set(\.$query.email, $0.isEmpty ? nil : $0) }
+              send: { UserSearchComponent.Action.set(\.$query.email, $0.isEmpty ? nil : $0) }
             ),
             prompt: Text("Enter email"),
             label: { Text("Email") }
@@ -55,7 +55,7 @@ public struct UserSearchView: View {
           TextField(
             text: viewStore.binding(
               get: { $0.query.phone ?? "" },
-              send: { UserSearchAction.set(\.$query.phone, $0.isEmpty ? nil : $0) }
+              send: { UserSearchComponent.Action.set(\.$query.phone, $0.isEmpty ? nil : $0) }
             ),
             prompt: Text("Enter phone"),
             label: { Text("Phone") }
@@ -124,7 +124,7 @@ public struct UserSearchView: View {
       .background(NavigationLinkWithStore(
         store.scope(
           state: \.contact,
-          action: UserSearchAction.contact
+          action: UserSearchComponent.Action.contact
         ),
         onDeactivate: { viewStore.send(.didDismissContact) },
         destination: ContactView.init(store:)
@@ -137,9 +137,8 @@ public struct UserSearchView: View {
 public struct UserSearchView_Previews: PreviewProvider {
   public static var previews: some View {
     UserSearchView(store: Store(
-      initialState: UserSearchState(),
-      reducer: .empty,
-      environment: ()
+      initialState: UserSearchComponent.State(),
+      reducer: EmptyReducer()
     ))
   }
 }
diff --git a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchComponentTests.swift
similarity index 81%
rename from Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift
rename to Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchComponentTests.swift
index 33f1edb9..97ca5fae 100644
--- a/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchFeatureTests.swift
+++ b/Examples/xx-messenger/Tests/UserSearchFeatureTests/UserSearchComponentTests.swift
@@ -6,12 +6,11 @@ import XXClient
 import XXMessengerClient
 @testable import UserSearchFeature
 
-final class UserSearchFeatureTests: XCTestCase {
+final class UserSearchComponentTests: XCTestCase {
   func testSearch() {
     let store = TestStore(
-      initialState: UserSearchState(),
-      reducer: userSearchReducer,
-      environment: .unimplemented
+      initialState: UserSearchComponent.State(),
+      reducer: UserSearchComponent()
     )
 
     var didSearchWithQuery: [MessengerSearchContacts.Query] = []
@@ -43,9 +42,9 @@ final class UserSearchFeatureTests: XCTestCase {
     contact4.getFactsFromContact.run = { _ in throw GetFactsFromContactError() }
     let contacts = [contact1, contact2, contact3, contact4]
 
-    store.environment.bgQueue = .immediate
-    store.environment.mainQueue = .immediate
-    store.environment.messenger.searchContacts.run = { query in
+    store.dependencies.app.bgQueue = .immediate
+    store.dependencies.app.mainQueue = .immediate
+    store.dependencies.app.messenger.searchContacts.run = { query in
       didSearchWithQuery.append(query)
       return contacts
     }
@@ -93,17 +92,16 @@ final class UserSearchFeatureTests: XCTestCase {
 
   func testSearchFailure() {
     let store = TestStore(
-      initialState: UserSearchState(),
-      reducer: userSearchReducer,
-      environment: .unimplemented
+      initialState: UserSearchComponent.State(),
+      reducer: UserSearchComponent()
     )
 
     struct Failure: Error {}
     let failure = Failure()
 
-    store.environment.bgQueue = .immediate
-    store.environment.mainQueue = .immediate
-    store.environment.messenger.searchContacts.run = { _ in throw failure }
+    store.dependencies.app.bgQueue = .immediate
+    store.dependencies.app.mainQueue = .immediate
+    store.dependencies.app.messenger.searchContacts.run = { _ in throw failure }
 
     store.send(.searchTapped) {
       $0.focusedField = nil
@@ -121,7 +119,7 @@ final class UserSearchFeatureTests: XCTestCase {
 
   func testResultTapped() {
     let store = TestStore(
-      initialState: UserSearchState(
+      initialState: UserSearchComponent.State(
         results: [
           .init(
             id: "contact-id".data(using: .utf8)!,
@@ -129,12 +127,11 @@ final class UserSearchFeatureTests: XCTestCase {
           )
         ]
       ),
-      reducer: userSearchReducer,
-      environment: .unimplemented
+      reducer: UserSearchComponent()
     )
 
     store.send(.resultTapped(id: "contact-id".data(using: .utf8)!)) {
-      $0.contact = ContactState(
+      $0.contact = ContactComponent.State(
         id: "contact-id".data(using: .utf8)!,
         xxContact: .unimplemented("contact-data".data(using: .utf8)!)
       )
@@ -143,13 +140,12 @@ final class UserSearchFeatureTests: XCTestCase {
 
   func testDismissingContact() {
     let store = TestStore(
-      initialState: UserSearchState(
-        contact: ContactState(
+      initialState: UserSearchComponent.State(
+        contact: ContactComponent.State(
           id: "contact-id".data(using: .utf8)!
         )
       ),
-      reducer: userSearchReducer,
-      environment: .unimplemented
+      reducer: UserSearchComponent()
     )
 
     store.send(.didDismissContact) {
-- 
GitLab