Skip to content
Snippets Groups Projects
Commit 08454d98 authored by Dariusz Rybicki's avatar Dariusz Rybicki
Browse files

Migrate UserSearchFeature to ReducerProtocol

parent 59b4b115
No related branches found
No related tags found
2 merge requests!126Migrate example app to ComposableArchitecture's ReducerProtocol,!102Release 1.0.0
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() }
)
}
}
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() }
)
...@@ -5,21 +5,21 @@ import SwiftUI ...@@ -5,21 +5,21 @@ import SwiftUI
import XXMessengerClient import XXMessengerClient
public struct UserSearchView: View { public struct UserSearchView: View {
public init(store: Store<UserSearchState, UserSearchAction>) { public init(store: StoreOf<UserSearchComponent>) {
self.store = store self.store = store
} }
let store: Store<UserSearchState, UserSearchAction> let store: StoreOf<UserSearchComponent>
@FocusState var focusedField: UserSearchState.Field? @FocusState var focusedField: UserSearchComponent.State.Field?
struct ViewState: Equatable { struct ViewState: Equatable {
var focusedField: UserSearchState.Field? var focusedField: UserSearchComponent.State.Field?
var query: MessengerSearchContacts.Query var query: MessengerSearchContacts.Query
var isSearching: Bool var isSearching: Bool
var failure: String? var failure: String?
var results: IdentifiedArrayOf<UserSearchState.Result> var results: IdentifiedArrayOf<UserSearchComponent.State.Result>
init(state: UserSearchState) { init(state: UserSearchComponent.State) {
focusedField = state.focusedField focusedField = state.focusedField
query = state.query query = state.query
isSearching = state.isSearching isSearching = state.isSearching
...@@ -35,7 +35,7 @@ public struct UserSearchView: View { ...@@ -35,7 +35,7 @@ public struct UserSearchView: View {
TextField( TextField(
text: viewStore.binding( text: viewStore.binding(
get: { $0.query.username ?? "" }, 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"), prompt: Text("Enter username"),
label: { Text("Username") } label: { Text("Username") }
...@@ -45,7 +45,7 @@ public struct UserSearchView: View { ...@@ -45,7 +45,7 @@ public struct UserSearchView: View {
TextField( TextField(
text: viewStore.binding( text: viewStore.binding(
get: { $0.query.email ?? "" }, 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"), prompt: Text("Enter email"),
label: { Text("Email") } label: { Text("Email") }
...@@ -55,7 +55,7 @@ public struct UserSearchView: View { ...@@ -55,7 +55,7 @@ public struct UserSearchView: View {
TextField( TextField(
text: viewStore.binding( text: viewStore.binding(
get: { $0.query.phone ?? "" }, 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"), prompt: Text("Enter phone"),
label: { Text("Phone") } label: { Text("Phone") }
...@@ -124,7 +124,7 @@ public struct UserSearchView: View { ...@@ -124,7 +124,7 @@ public struct UserSearchView: View {
.background(NavigationLinkWithStore( .background(NavigationLinkWithStore(
store.scope( store.scope(
state: \.contact, state: \.contact,
action: UserSearchAction.contact action: UserSearchComponent.Action.contact
), ),
onDeactivate: { viewStore.send(.didDismissContact) }, onDeactivate: { viewStore.send(.didDismissContact) },
destination: ContactView.init(store:) destination: ContactView.init(store:)
...@@ -137,9 +137,8 @@ public struct UserSearchView: View { ...@@ -137,9 +137,8 @@ public struct UserSearchView: View {
public struct UserSearchView_Previews: PreviewProvider { public struct UserSearchView_Previews: PreviewProvider {
public static var previews: some View { public static var previews: some View {
UserSearchView(store: Store( UserSearchView(store: Store(
initialState: UserSearchState(), initialState: UserSearchComponent.State(),
reducer: .empty, reducer: EmptyReducer()
environment: ()
)) ))
} }
} }
......
...@@ -6,12 +6,11 @@ import XXClient ...@@ -6,12 +6,11 @@ import XXClient
import XXMessengerClient import XXMessengerClient
@testable import UserSearchFeature @testable import UserSearchFeature
final class UserSearchFeatureTests: XCTestCase { final class UserSearchComponentTests: XCTestCase {
func testSearch() { func testSearch() {
let store = TestStore( let store = TestStore(
initialState: UserSearchState(), initialState: UserSearchComponent.State(),
reducer: userSearchReducer, reducer: UserSearchComponent()
environment: .unimplemented
) )
var didSearchWithQuery: [MessengerSearchContacts.Query] = [] var didSearchWithQuery: [MessengerSearchContacts.Query] = []
...@@ -43,9 +42,9 @@ final class UserSearchFeatureTests: XCTestCase { ...@@ -43,9 +42,9 @@ final class UserSearchFeatureTests: XCTestCase {
contact4.getFactsFromContact.run = { _ in throw GetFactsFromContactError() } contact4.getFactsFromContact.run = { _ in throw GetFactsFromContactError() }
let contacts = [contact1, contact2, contact3, contact4] let contacts = [contact1, contact2, contact3, contact4]
store.environment.bgQueue = .immediate store.dependencies.app.bgQueue = .immediate
store.environment.mainQueue = .immediate store.dependencies.app.mainQueue = .immediate
store.environment.messenger.searchContacts.run = { query in store.dependencies.app.messenger.searchContacts.run = { query in
didSearchWithQuery.append(query) didSearchWithQuery.append(query)
return contacts return contacts
} }
...@@ -93,17 +92,16 @@ final class UserSearchFeatureTests: XCTestCase { ...@@ -93,17 +92,16 @@ final class UserSearchFeatureTests: XCTestCase {
func testSearchFailure() { func testSearchFailure() {
let store = TestStore( let store = TestStore(
initialState: UserSearchState(), initialState: UserSearchComponent.State(),
reducer: userSearchReducer, reducer: UserSearchComponent()
environment: .unimplemented
) )
struct Failure: Error {} struct Failure: Error {}
let failure = Failure() let failure = Failure()
store.environment.bgQueue = .immediate store.dependencies.app.bgQueue = .immediate
store.environment.mainQueue = .immediate store.dependencies.app.mainQueue = .immediate
store.environment.messenger.searchContacts.run = { _ in throw failure } store.dependencies.app.messenger.searchContacts.run = { _ in throw failure }
store.send(.searchTapped) { store.send(.searchTapped) {
$0.focusedField = nil $0.focusedField = nil
...@@ -121,7 +119,7 @@ final class UserSearchFeatureTests: XCTestCase { ...@@ -121,7 +119,7 @@ final class UserSearchFeatureTests: XCTestCase {
func testResultTapped() { func testResultTapped() {
let store = TestStore( let store = TestStore(
initialState: UserSearchState( initialState: UserSearchComponent.State(
results: [ results: [
.init( .init(
id: "contact-id".data(using: .utf8)!, id: "contact-id".data(using: .utf8)!,
...@@ -129,12 +127,11 @@ final class UserSearchFeatureTests: XCTestCase { ...@@ -129,12 +127,11 @@ final class UserSearchFeatureTests: XCTestCase {
) )
] ]
), ),
reducer: userSearchReducer, reducer: UserSearchComponent()
environment: .unimplemented
) )
store.send(.resultTapped(id: "contact-id".data(using: .utf8)!)) { store.send(.resultTapped(id: "contact-id".data(using: .utf8)!)) {
$0.contact = ContactState( $0.contact = ContactComponent.State(
id: "contact-id".data(using: .utf8)!, id: "contact-id".data(using: .utf8)!,
xxContact: .unimplemented("contact-data".data(using: .utf8)!) xxContact: .unimplemented("contact-data".data(using: .utf8)!)
) )
...@@ -143,13 +140,12 @@ final class UserSearchFeatureTests: XCTestCase { ...@@ -143,13 +140,12 @@ final class UserSearchFeatureTests: XCTestCase {
func testDismissingContact() { func testDismissingContact() {
let store = TestStore( let store = TestStore(
initialState: UserSearchState( initialState: UserSearchComponent.State(
contact: ContactState( contact: ContactComponent.State(
id: "contact-id".data(using: .utf8)! id: "contact-id".data(using: .utf8)!
) )
), ),
reducer: userSearchReducer, reducer: UserSearchComponent()
environment: .unimplemented
) )
store.send(.didDismissContact) { store.send(.didDismissContact) {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment