Skip to content

Commit d5409e3

Browse files
committed
chore: Add autoSuggest higher-order Reducer
1 parent fb63d7b commit d5409e3

File tree

1 file changed

+160
-151
lines changed

1 file changed

+160
-151
lines changed

Prose/ProseLib/Sources/SearchSuggestionsFeature/BetterImplementation/AutoSuggestReducer.swift

+160-151
Original file line numberDiff line numberDiff line change
@@ -4,56 +4,56 @@
44
//
55

66
import AutoSuggestClient
7+
import Combine
78
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+
}
5757

5858
public struct AutoSuggestEnvironment<T: Hashable> {
5959
/// The client performing search queries.
@@ -68,105 +68,114 @@ public struct AutoSuggestEnvironment<T: Hashable> {
6868
}
6969
}
7070

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

Comments
 (0)