4
4
//
5
5
6
6
import AutoSuggestClient
7
+ import Combine
7
8
import CombineSchedulers
8
- // import ComposableArchitecture
9
- // import Foundation
10
- // import Toolbox
11
- //
12
- // public extension DispatchQueue.SchedulerTimeType.Stride {
13
- // static let autoSuggestDebounceDuration: Self = .milliseconds(300)
14
- // }
15
- //
16
- // public struct AutoSuggestState<T: Hashable & Identifiable>: Equatable {
17
- //// /// Our token for cancelling effects. We need a one token per instance of auto-suggest reducer
18
- //// /// otherwise [Effect.throttle](x-source-tag://Effect.throttle) won't work as expected, because
19
- //// /// of its side-effecty behavior. While not a problem in practice it would still break our tests.
20
- //// fileprivate var debounceToken = Toolbox.Current.uuid()
21
- //// fileprivate var loadSuggestionsToken = Toolbox.Current.uuid()
22
- //
23
- //// /// - Note: `internal` for testing, could be `fileprivate` otherwise.
24
- //// internal var isFirstSearchQuery = true
25
- //
26
- // public var noResultText: String
27
- //
28
- // var content: Loadable<[AutoSuggestSection<T>]>
29
- //
30
- // public init(noResultText: String) {
31
- // self.noResultText = noResultText
32
- // }
33
- // }
34
- //
35
- // public struct AutoSuggestState<T: Hashable>: Equatable {
36
- //
37
- // public var sections = [AutoSuggestSection<T>]()
38
- // public var loadingState: LoadingState?
39
- // var searchQuery: String?
40
- //
41
- // /// Trim search query on both sides.
42
- // public var trimmedQuery: String? {
43
- // let trimmed = self.searchQuery?.trimmingCharacters(in: .whitespacesAndNewlines)
44
- // return trimmed.flatMap { $0.isEmpty ? nil : $0 }
45
- // }
46
- // }
47
- //
48
- // public enum AutoSuggestAction<T: Hashable>: Equatable {
49
- // case onAppear
50
- // case onDisappear
51
- //
52
- // case searchQueryChanged(String?)
53
- // case itemSelected(T)
54
- // case autoSuggestResponse(Result<[AutoSuggestSection<T>], EquatableError>)
55
- // case retryButtonTapped
56
- // }
9
+ import ComposableArchitecture
10
+ import Toolbox
11
+
12
+ public extension DispatchQueue . SchedulerTimeType . Stride {
13
+ static let autoSuggestDebounceDuration : Self = . milliseconds( 300 )
14
+ }
15
+
16
+ struct UniqueCancellationToken : Hashable {
17
+ let uuid : UUID
18
+ static func uuid( ) -> Self { Self ( uuid: UUID ( ) ) }
19
+ }
20
+
21
+ public struct AutoSuggestState < T: Hashable & Identifiable > : Equatable {
22
+ /// Our token for cancelling effects. We need a one token per instance of auto-suggest reducer
23
+ /// otherwise [Effect.throttle](x-source-tag://Effect.throttle) won't work as expected, because
24
+ /// of its side-effecty behavior. While not a problem in practice it would still break our tests.
25
+ fileprivate var debounceToken = UniqueCancellationToken . uuid ( )
26
+ fileprivate var loadSuggestionsToken = UniqueCancellationToken . uuid ( )
27
+
28
+ /// - Note: `internal` for testing, could be `fileprivate` otherwise.
29
+ internal var isFirstSearchQuery = true
30
+
31
+ var searchQuery : String ?
32
+
33
+ var content : Loadable < [ AutoSuggestSection < T > ] > = . notRequested
34
+
35
+ var noResultText : String
36
+
37
+ /// Trim search query on both sides.
38
+ public var trimmedQuery : String ? {
39
+ let trimmed = self . searchQuery? . trimmingCharacters ( in: . whitespacesAndNewlines)
40
+ return trimmed. flatMap { $0. isEmpty ? nil : $0 }
41
+ }
42
+
43
+ public init ( noResultText: String ) {
44
+ self . noResultText = noResultText
45
+ }
46
+ }
47
+
48
+ public enum AutoSuggestAction < T: Hashable > : Equatable {
49
+ case onAppear
50
+ case onDisappear
51
+
52
+ case searchQueryChanged( String ? )
53
+ case itemSelected( T )
54
+ case autoSuggestResponse( Result < [ AutoSuggestSection < T > ] , EquatableError > )
55
+ case retryButtonTapped
56
+ }
57
57
58
58
public struct AutoSuggestEnvironment < T: Hashable > {
59
59
/// The client performing search queries.
@@ -68,105 +68,114 @@ public struct AutoSuggestEnvironment<T: Hashable> {
68
68
}
69
69
}
70
70
71
- // public extension Reducer {
72
- // /// Enhances a reducer with auto-suggest logic.
73
- // func autoSuggest<T: Hashable>(
74
- // state: WritableKeyPath<State, AutoSuggestState<T>>,
75
- // action: CasePath<Action, AutoSuggestAction<T>>,
76
- // environment: @escaping (Environment) -> AutoSuggestEnvironment<T>
77
- // ) -> Reducer {
78
- // Reducer.combine(
79
- // self,
80
- // Reducer<
81
- // AutoSuggestState<T>,
82
- // AutoSuggestAction<T>,
83
- // AutoSuggestEnvironment<T>
84
- // > { state, action, environment in
85
- // switch action {
86
- // case let .searchQueryChanged(query):
87
- // let query: String? = state.trimmedQuery
88
- // state.searchQuery = query
89
- //
90
- // // Only show loading indicator if we don't have any results currently.
91
- // if state.sections.isEmpty {
92
- // state.loadingState = .loading
93
- // }
94
- //
95
- // let debounceToken = state.debounceToken
96
- // let isFirstSearchQuery = state.isFirstSearchQuery
97
- //
98
- // state.isFirstSearchQuery = false
99
- //
100
- // return Just(query)
101
- // .setFailureType(to: EquatableError.self)
102
- // .flatMap { query -> Effect<String?, EquatableError> in
103
- // // Don't debounce the first query. This removes the initial delay when the
104
- // // AutoSuggestViewController is presented the first time.
105
- // if isFirstSearchQuery {
106
- // return Just(query)
107
- // .setFailureType(to: EquatableError.self)
108
- // .eraseToEffect()
109
- // }
110
- // return Just(query)
111
- // .setFailureType(to: EquatableError.self)
112
- // .eraseToEffect()
113
- // .debounce(
114
- // id: debounceToken,
115
- // for: .autoSuggestDebounceDuration,
116
- // scheduler: environment.mainQueue
117
- // )
118
- // }
119
- // .flatMap { _ -> AnyPublisher<[AutoSuggestSection<T>], EquatableError> in
120
- // environment.client.loadSuggestions(query)
121
- // .mapError(EquatableError.init)
122
- // .eraseToAnyPublisher()
123
- // }
124
- // .receive(on: environment.mainQueue)
125
- // .catchToEffect()
126
- // .map(AutoSuggestAction.autoSuggestResponse)
127
- // .cancellable(id: state.loadSuggestionsToken, cancelInFlight: true)
128
- //
129
- // case .itemSelected:
130
- // return .none
131
- //
132
- // case let .autoSuggestResponse(.success(sections)):
133
- // state.sections = sections.filter { !$0.items.isEmpty }
134
- // state.loadingState = state.sections.isEmpty
135
- // ? .empty(message: state.noResultText)
136
- // : nil
137
- // return .none
138
- //
139
- // case let .autoSuggestResponse(.failure(error)):
140
- // state.sections = []
141
- // state.loadingState = .error(EquatableError(error))
142
- // return .none
143
- //
144
- // case .retryButtonTapped:
145
- // state.loadingState = .loading
146
- //
147
- // return environment.client.loadSuggestions(state.trimmedQuery)
148
- // .mapError(EquatableError.init)
149
- // .receive(on: environment.mainQueue)
150
- // .catchToEffect()
151
- // .map(AutoSuggestAction.autoSuggestResponse)
152
- // .cancellable(id: state.loadSuggestionsToken, cancelInFlight: true)
153
- //
154
- // case .onAppear:
155
- // state.isFirstSearchQuery = true
156
- // return .none
157
- //
158
- // case .onDisappear:
159
- // state.searchQuery = nil
160
- // state.loadingState = nil
161
- // state.sections = []
162
- //
163
- // return .merge(
164
- // .cancel(id: state.debounceToken),
165
- // .cancel(id: state.loadSuggestionsToken)
166
- // )
167
- // }
168
- // }
169
- // .pullback(state: state, action: action, environment: environment)
170
- // )
171
- // }
172
- // }
71
+ public extension Reducer {
72
+ /// Enhances a reducer with auto-suggest logic.
73
+ func autoSuggest< T: Hashable > (
74
+ state: WritableKeyPath < State , AutoSuggestState < T > > ,
75
+ action: CasePath < Action , AutoSuggestAction < T > > ,
76
+ environment: @escaping ( Environment ) -> AutoSuggestEnvironment < T >
77
+ ) -> Reducer {
78
+ Reducer . combine (
79
+ self ,
80
+ Reducer <
81
+ AutoSuggestState < T > ,
82
+ AutoSuggestAction < T > ,
83
+ AutoSuggestEnvironment < T >
84
+ > { state, action, environment in
85
+ switch action {
86
+ case let . searchQueryChanged( query) :
87
+ state. searchQuery = query
88
+
89
+ guard let query: String = state. trimmedQuery else {
90
+ // There is nothing to do if the query is empty (e.g. just spaces)
91
+ return . none
92
+ }
93
+
94
+ // Only show loading indicator if we don't have any results currently.
95
+ if state. content. value == nil {
96
+ state. content. transitionToLoading ( )
97
+ }
98
+
99
+ let debounceToken = state. debounceToken
100
+ let isFirstSearchQuery = state. isFirstSearchQuery
101
+
102
+ state. isFirstSearchQuery = false
103
+
104
+ func debounceIfNeeded( _ query: String ) -> Effect < String , EquatableError > {
105
+ // Don't debounce the first query. This removes the initial delay when the
106
+ // `AutoSuggestViewController` is presented the first time.
107
+ if isFirstSearchQuery {
108
+ return Just ( query)
109
+ . setFailureType ( to: EquatableError . self)
110
+ . eraseToEffect ( )
111
+ }
112
+ return Just ( query)
113
+ . setFailureType ( to: EquatableError . self)
114
+ . eraseToEffect ( )
115
+ . debounce (
116
+ id: debounceToken,
117
+ for: . autoSuggestDebounceDuration,
118
+ scheduler: environment. mainQueue
119
+ )
120
+ }
121
+
122
+ func executeQuery( _ query: String ) -> AnyPublisher < [ AutoSuggestSection < T > ] , EquatableError > {
123
+ environment. client. loadSuggestions ( query, Set ( ) )
124
+ . mapError ( EquatableError . init)
125
+ . eraseToAnyPublisher ( )
126
+ }
127
+
128
+ return Just ( query)
129
+ . setFailureType ( to: EquatableError . self)
130
+ . flatMap ( debounceIfNeeded)
131
+ . flatMap ( executeQuery)
132
+ . receive ( on: environment. mainQueue)
133
+ . catchToEffect ( )
134
+ . map ( AutoSuggestAction . autoSuggestResponse)
135
+ . cancellable ( id: state. loadSuggestionsToken, cancelInFlight: true )
136
+
137
+ case . itemSelected:
138
+ return . none
139
+
140
+ case let . autoSuggestResponse( . success( sections) ) :
141
+ let sections : [ AutoSuggestSection < T > ] = sections. filter { !$0. items. isEmpty }
142
+ state. content = . loaded( sections)
143
+ return . none
144
+
145
+ case let . autoSuggestResponse( . failure( error) ) :
146
+ state. content. transitionToError ( EquatableError ( error) )
147
+ return . none
148
+
149
+ case . retryButtonTapped:
150
+ guard let query = state. trimmedQuery else {
151
+ // There is nothing to do if the query is empty (e.g. just spaces)
152
+ return . none
153
+ }
154
+
155
+ state. content. transitionToLoading ( )
156
+
157
+ return environment. client. loadSuggestions ( query, Set ( ) )
158
+ . mapError ( EquatableError . init)
159
+ . receive ( on: environment. mainQueue)
160
+ . catchToEffect ( )
161
+ . map ( AutoSuggestAction . autoSuggestResponse)
162
+ . cancellable ( id: state. loadSuggestionsToken, cancelInFlight: true )
163
+
164
+ case . onAppear:
165
+ state. isFirstSearchQuery = true
166
+ return . none
167
+
168
+ case . onDisappear:
169
+ state. searchQuery = nil
170
+ state. content = . notRequested
171
+
172
+ return Effect . merge (
173
+ Effect . cancel ( id: state. debounceToken) ,
174
+ Effect . cancel ( id: state. loadSuggestionsToken)
175
+ )
176
+ }
177
+ }
178
+ . pullback ( state: state, action: action, environment: environment)
179
+ )
180
+ }
181
+ }
0 commit comments