Skip to content

Commit 37fb876

Browse files
authored
Merge pull request #13 from Lickability/kpa/testing-composibility-shared-state-and-viewstore
Shared state composable ViewStore example
2 parents 888cec4 + d592a26 commit 37fb876

12 files changed

Lines changed: 581 additions & 22 deletions

File tree

File renamed without changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// Banner.swift
3+
// ViewStore
4+
//
5+
// Created by Kenneth Ackerson on 4/11/23.
6+
//
7+
8+
import Foundation
9+
10+
/// Container for text intended to be displayed at the top of a screen.
11+
struct Banner {
12+
13+
/// The text to be displayed.
14+
let title: String
15+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//
2+
// BannerDataStore.swift
3+
// ViewStore
4+
//
5+
// Created by Kenneth Ackerson on 4/3/23.
6+
//
7+
8+
import Foundation
9+
import Combine
10+
11+
typealias BannerDataStoreType = Store<BannerDataStore.State, BannerDataStore.Action>
12+
13+
/// A `Store` that is responsible for being the source of truth for a `Banner`. This includes updating locally and remotely. Not meant to be used to drive a `View`, but rather meant to be composed into other `Store`s.
14+
final class BannerDataStore: Store {
15+
16+
// MARK: - Store
17+
18+
struct State {
19+
20+
/// Initial state of the banner data store.
21+
static let initial = State(banner: .init(title: "Banner"), networkState: .notStarted)
22+
23+
/// The source of truth of the banner model object.
24+
let banner: Banner
25+
26+
/// Networking state of the request to upload a new banner model to the server.
27+
let networkState: MockBannerNetworkStateController.NetworkState
28+
}
29+
30+
enum Action {
31+
/// Changes the local copy of the banner model synchronously.
32+
case updateBannerLocally(Banner)
33+
34+
/// Sends the banner to the server and then updates the model locally if it was successful.
35+
case uploadBanner(Banner)
36+
37+
/// Clears the underlying networking state back to `notStarted`.
38+
case clearNetworkingState
39+
}
40+
41+
@Published var state: State = BannerDataStore.State.initial
42+
43+
var publishedState: AnyPublisher<State, Never> {
44+
$state.eraseToAnyPublisher()
45+
}
46+
47+
// MARK: - BannerDataStore
48+
49+
private let bannerSubject = PassthroughSubject<Banner, Never>()
50+
private let network: MockBannerNetworkStateController = .init()
51+
private var cancellables = Set<AnyCancellable>()
52+
53+
/// Creates a new `BannerDataStore`
54+
init() {
55+
56+
let networkPublisher = network.publisher.prepend(.notStarted)
57+
let additionalActions = networkPublisher.compactMap { $0.banner }.map { Action.updateBannerLocally($0) }
58+
59+
bannerSubject
60+
.prepend(state.banner)
61+
.combineLatest(network.publisher.prepend(.notStarted))
62+
.map { banner, networkState in
63+
return State(banner: banner, networkState: networkState)
64+
}
65+
.assign(to: &$state)
66+
67+
pipeActions(publisher: additionalActions, storeIn: &cancellables)
68+
}
69+
70+
// MARK: - Store
71+
72+
func send(_ action: Action) {
73+
switch action {
74+
case .updateBannerLocally(let banner):
75+
bannerSubject.send(banner)
76+
case .uploadBanner(let banner):
77+
network.upload(banner: banner)
78+
case .clearNetworkingState:
79+
network.reset()
80+
}
81+
}
82+
83+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//
2+
// MockBannerNetworkStateController.swift
3+
// ViewStore
4+
//
5+
// Created by Kenneth Ackerson on 4/3/23.
6+
//
7+
8+
import Foundation
9+
import Combine
10+
11+
/// A really contrived fake interface similar to networking state controller for updating `Banner` models on a nonexistent server.
12+
final class MockBannerNetworkStateController {
13+
14+
/// Represents the state of a network request for a banner.
15+
enum NetworkState {
16+
17+
/// The network request has not started yet.
18+
case notStarted
19+
20+
/// The network request is currently in progress.
21+
case inProgress
22+
23+
/// The network request has finished and resulted in either success or failure.
24+
/// - Parameter Result: A result type containing a `Banner` on success or a `NetworkError` on failure.
25+
case finished(Result<Banner, NetworkError>)
26+
27+
/// The `Banner` object obtained from a successful network request, if available.
28+
var banner: Banner? {
29+
switch self {
30+
case .inProgress, .notStarted:
31+
return nil
32+
case .finished(let result):
33+
return try? result.get()
34+
}
35+
}
36+
37+
/// The error obtained from a failed network request, if available.
38+
var error: NetworkError? {
39+
switch self {
40+
case .notStarted, .inProgress:
41+
return nil
42+
case .finished(let result):
43+
do {
44+
_ = try result.get()
45+
return nil
46+
} catch let error as NetworkError {
47+
return error
48+
} catch {
49+
assertionFailure("unhandled error")
50+
return nil
51+
}
52+
}
53+
}
54+
55+
/// Possible errors that can occur when using this controller.
56+
enum NetworkError: LocalizedError {
57+
58+
/// A mocked error that is expected.
59+
case intentionalFailure
60+
61+
// MARK - LocalizedError
62+
63+
var errorDescription: String? {
64+
switch self {
65+
case .intentionalFailure:
66+
return "This is an expected error used for testing error handling."
67+
}
68+
}
69+
}
70+
}
71+
72+
/// A publisher that sends updates of the `NetworkState`.
73+
public var publisher: PassthroughSubject<NetworkState, Never> = .init()
74+
75+
/// Uploads a `Banner` to a fake server.
76+
/// - Parameter banner: The `Banner` to upload.
77+
func upload(banner: Banner) {
78+
self.publisher.send(.inProgress)
79+
80+
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
81+
82+
// Pick whether you would like to get a successful (`.finished(.success...`) state or any error for this "network request".
83+
84+
//self.publisher.send(.finished(.success(banner)))
85+
86+
self.publisher.send(.finished(.failure(.intentionalFailure)))
87+
88+
}
89+
90+
}
91+
92+
/// Resets the current networking state to `notStarted`.
93+
func reset() {
94+
self.publisher.send(.notStarted)
95+
}
96+
97+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//
2+
// BannerUpdateView.swift
3+
// ViewStore
4+
//
5+
// Created by Kenneth Ackerson on 4/3/23.
6+
//
7+
8+
import Foundation
9+
import SwiftUI
10+
import Combine
11+
12+
/// A really simple view that allows you to type and upload a new Banner.
13+
struct BannerUpdateView<Store: BannerUpdateViewStoreType>: View {
14+
15+
@Environment(\.dismiss) private var dismiss
16+
17+
@StateObject private var store: Store
18+
19+
/// Creates a new `BannerUpdateView`.
20+
/// - Parameter store: The `Store` that drives this view.
21+
init(store: @autoclosure @escaping () -> Store) {
22+
self._store = StateObject(wrappedValue: store())
23+
}
24+
25+
var body: some View {
26+
VStack {
27+
28+
VStack {
29+
Spacer()
30+
31+
TextField("", text: store.workingTitle)
32+
.multilineTextAlignment(.center)
33+
.padding(10)
34+
.font(.system(size: 36))
35+
36+
Spacer()
37+
}
38+
.padding(.horizontal, 30)
39+
40+
Button {
41+
store.send(.submit)
42+
} label: {
43+
Group {
44+
if store.state.dismissable {
45+
Text("Submit")
46+
} else {
47+
ProgressView()
48+
}
49+
}
50+
.foregroundColor(.white)
51+
.padding(.vertical, 20)
52+
.padding(.horizontal, 40)
53+
.background {
54+
RoundedRectangle(cornerRadius: 10)
55+
.foregroundColor(.blue)
56+
}
57+
}
58+
.disabled(!store.state.dismissable)
59+
.padding(.bottom, 10)
60+
}
61+
.onChange(of: store.state.success) { success in
62+
if success {
63+
dismiss()
64+
}
65+
}
66+
.alert(isPresented: store.isErrorPresented, error: store.state.error) { _ in
67+
68+
} message: { error in
69+
Text("Error")
70+
}
71+
72+
}
73+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//
2+
// BannerUpdateViewStore.swift
3+
// ViewStore
4+
//
5+
// Created by Kenneth Ackerson on 4/3/23.
6+
//
7+
8+
import Foundation
9+
import Combine
10+
import SwiftUI
11+
import CasePaths
12+
13+
typealias BannerUpdateViewStoreType = Store<BannerUpdateViewStore.State, BannerUpdateViewStore.Action>
14+
15+
/// A `Store` that drives a view that can update a `Banner` through any `BannerDataStoreType`, and exposes view-specific state such as a working copy of the banner, the possible networking error, etc.
16+
final class BannerUpdateViewStore: Store {
17+
18+
// MARK: - Store
19+
20+
/// Represents the state of the `BannerUpdateViewStore`
21+
struct State {
22+
/// Stores the state of the nested `BannerDataStore`
23+
let bannerViewState: BannerDataStore.State
24+
25+
/// A working copy of the banner being updated, to be uploaded if the `submit` action is sent.
26+
let workingCopy: Banner
27+
28+
/// Returns true if the network state is not started or finished, false if it's in progress
29+
var dismissable: Bool {
30+
switch bannerViewState.networkState {
31+
case .notStarted, .finished:
32+
return true
33+
case .inProgress:
34+
return false
35+
}
36+
}
37+
38+
/// Returns true if the network state is finished and the result is successful, false otherwise
39+
var success: Bool {
40+
switch bannerViewState.networkState {
41+
case .notStarted, .inProgress:
42+
return false
43+
case .finished(let result):
44+
return (try? result.get()) != nil
45+
}
46+
}
47+
48+
// Returns a `NetworkError` if there is an error in the network state when it's finished, otherwise returns nil
49+
var error: MockBannerNetworkStateController.NetworkState.NetworkError? {
50+
return bannerViewState.networkState.error
51+
}
52+
}
53+
54+
enum Action {
55+
/// Action to update the title of the banner with a given string
56+
case updateTitle(String)
57+
58+
/// Action to dismiss an error
59+
case dismissError
60+
61+
/// Action to submit the updated working copy banner to the network
62+
case submit
63+
}
64+
65+
@Published var state: State
66+
var publishedState: AnyPublisher<State, Never> {
67+
return $state.eraseToAnyPublisher()
68+
}
69+
70+
// MARK: - BannerUpdateViewStore
71+
72+
private let bannerDataStore: any BannerDataStoreType
73+
74+
private let newTitlePublisher = PassthroughSubject<String, Never>()
75+
76+
/// Creates a new `BannerUpdateViewStore`.
77+
/// - Parameter bannerDataStore: The data `Store` responsible for updating the banner on the network and its source of truth in the application.
78+
init(bannerDataStore: any BannerDataStoreType) {
79+
self.bannerDataStore = bannerDataStore
80+
81+
state = State(bannerViewState: bannerDataStore.state, workingCopy: bannerDataStore.state.banner)
82+
83+
bannerDataStore
84+
.publishedState
85+
.combineLatest(newTitlePublisher.map(Banner.init).prepend(state.workingCopy))
86+
.map { bannerState, workingCopy in
87+
State(bannerViewState: bannerState, workingCopy: workingCopy)
88+
}
89+
.assign(to: &$state)
90+
}
91+
92+
// MARK: - Store
93+
94+
func send(_ action: Action) {
95+
switch action {
96+
case .updateTitle(let title):
97+
newTitlePublisher.send(title)
98+
case .submit:
99+
bannerDataStore.send(.uploadBanner(state.workingCopy))
100+
case .dismissError:
101+
bannerDataStore.send(.clearNetworkingState)
102+
}
103+
}
104+
105+
}
106+
107+
extension BannerUpdateViewStoreType {
108+
/// Computed property that creates a binding for the working title
109+
var workingTitle: Binding<String> {
110+
makeBinding(stateKeyPath: \.workingCopy.title, actionCasePath: /Action.updateTitle)
111+
}
112+
113+
/// Computed property that creates a binding for the error presentation state
114+
var isErrorPresented: Binding<Bool> {
115+
.init(get: {
116+
return self.state.error != nil
117+
}, set: { _ in
118+
self.send(.dismissError)
119+
})
120+
}
121+
}

0 commit comments

Comments
 (0)