Skip to content

Commit

Permalink
Merge pull request #13 from Lickability/kpa/testing-composibility-sha…
Browse files Browse the repository at this point in the history
…red-state-and-viewstore

Shared state composable ViewStore example
  • Loading branch information
Pearapps authored May 17, 2023
2 parents 888cec4 + d592a26 commit 37fb876
Show file tree
Hide file tree
Showing 12 changed files with 581 additions and 22 deletions.
File renamed without changes.
15 changes: 15 additions & 0 deletions Example/Photos/With Stores/Banner Feature/Banner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// Banner.swift
// ViewStore
//
// Created by Kenneth Ackerson on 4/11/23.
//

import Foundation

/// Container for text intended to be displayed at the top of a screen.
struct Banner {

/// The text to be displayed.
let title: String
}
83 changes: 83 additions & 0 deletions Example/Photos/With Stores/Banner Feature/BannerDataStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// BannerDataStore.swift
// ViewStore
//
// Created by Kenneth Ackerson on 4/3/23.
//

import Foundation
import Combine

typealias BannerDataStoreType = Store<BannerDataStore.State, BannerDataStore.Action>

/// 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.
final class BannerDataStore: Store {

// MARK: - Store

struct State {

/// Initial state of the banner data store.
static let initial = State(banner: .init(title: "Banner"), networkState: .notStarted)

/// The source of truth of the banner model object.
let banner: Banner

/// Networking state of the request to upload a new banner model to the server.
let networkState: MockBannerNetworkStateController.NetworkState
}

enum Action {
/// Changes the local copy of the banner model synchronously.
case updateBannerLocally(Banner)

/// Sends the banner to the server and then updates the model locally if it was successful.
case uploadBanner(Banner)

/// Clears the underlying networking state back to `notStarted`.
case clearNetworkingState
}

@Published var state: State = BannerDataStore.State.initial

var publishedState: AnyPublisher<State, Never> {
$state.eraseToAnyPublisher()
}

// MARK: - BannerDataStore

private let bannerSubject = PassthroughSubject<Banner, Never>()
private let network: MockBannerNetworkStateController = .init()
private var cancellables = Set<AnyCancellable>()

/// Creates a new `BannerDataStore`
init() {

let networkPublisher = network.publisher.prepend(.notStarted)
let additionalActions = networkPublisher.compactMap { $0.banner }.map { Action.updateBannerLocally($0) }

bannerSubject
.prepend(state.banner)
.combineLatest(network.publisher.prepend(.notStarted))
.map { banner, networkState in
return State(banner: banner, networkState: networkState)
}
.assign(to: &$state)

pipeActions(publisher: additionalActions, storeIn: &cancellables)
}

// MARK: - Store

func send(_ action: Action) {
switch action {
case .updateBannerLocally(let banner):
bannerSubject.send(banner)
case .uploadBanner(let banner):
network.upload(banner: banner)
case .clearNetworkingState:
network.reset()
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// MockBannerNetworkStateController.swift
// ViewStore
//
// Created by Kenneth Ackerson on 4/3/23.
//

import Foundation
import Combine

/// A really contrived fake interface similar to networking state controller for updating `Banner` models on a nonexistent server.
final class MockBannerNetworkStateController {

/// Represents the state of a network request for a banner.
enum NetworkState {

/// The network request has not started yet.
case notStarted

/// The network request is currently in progress.
case inProgress

/// The network request has finished and resulted in either success or failure.
/// - Parameter Result: A result type containing a `Banner` on success or a `NetworkError` on failure.
case finished(Result<Banner, NetworkError>)

/// The `Banner` object obtained from a successful network request, if available.
var banner: Banner? {
switch self {
case .inProgress, .notStarted:
return nil
case .finished(let result):
return try? result.get()
}
}

/// The error obtained from a failed network request, if available.
var error: NetworkError? {
switch self {
case .notStarted, .inProgress:
return nil
case .finished(let result):
do {
_ = try result.get()
return nil
} catch let error as NetworkError {
return error
} catch {
assertionFailure("unhandled error")
return nil
}
}
}

/// Possible errors that can occur when using this controller.
enum NetworkError: LocalizedError {

/// A mocked error that is expected.
case intentionalFailure

// MARK - LocalizedError

var errorDescription: String? {
switch self {
case .intentionalFailure:
return "This is an expected error used for testing error handling."
}
}
}
}

/// A publisher that sends updates of the `NetworkState`.
public var publisher: PassthroughSubject<NetworkState, Never> = .init()

/// Uploads a `Banner` to a fake server.
/// - Parameter banner: The `Banner` to upload.
func upload(banner: Banner) {
self.publisher.send(.inProgress)

DispatchQueue.main.asyncAfter(deadline: .now() + 2) {

// Pick whether you would like to get a successful (`.finished(.success...`) state or any error for this "network request".

//self.publisher.send(.finished(.success(banner)))

self.publisher.send(.finished(.failure(.intentionalFailure)))

}

}

/// Resets the current networking state to `notStarted`.
func reset() {
self.publisher.send(.notStarted)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// BannerUpdateView.swift
// ViewStore
//
// Created by Kenneth Ackerson on 4/3/23.
//

import Foundation
import SwiftUI
import Combine

/// A really simple view that allows you to type and upload a new Banner.
struct BannerUpdateView<Store: BannerUpdateViewStoreType>: View {

@Environment(\.dismiss) private var dismiss

@StateObject private var store: Store

/// Creates a new `BannerUpdateView`.
/// - Parameter store: The `Store` that drives this view.
init(store: @autoclosure @escaping () -> Store) {
self._store = StateObject(wrappedValue: store())
}

var body: some View {
VStack {

VStack {
Spacer()

TextField("", text: store.workingTitle)
.multilineTextAlignment(.center)
.padding(10)
.font(.system(size: 36))

Spacer()
}
.padding(.horizontal, 30)

Button {
store.send(.submit)
} label: {
Group {
if store.state.dismissable {
Text("Submit")
} else {
ProgressView()
}
}
.foregroundColor(.white)
.padding(.vertical, 20)
.padding(.horizontal, 40)
.background {
RoundedRectangle(cornerRadius: 10)
.foregroundColor(.blue)
}
}
.disabled(!store.state.dismissable)
.padding(.bottom, 10)
}
.onChange(of: store.state.success) { success in
if success {
dismiss()
}
}
.alert(isPresented: store.isErrorPresented, error: store.state.error) { _ in

} message: { error in
Text("Error")
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// BannerUpdateViewStore.swift
// ViewStore
//
// Created by Kenneth Ackerson on 4/3/23.
//

import Foundation
import Combine
import SwiftUI
import CasePaths

typealias BannerUpdateViewStoreType = Store<BannerUpdateViewStore.State, BannerUpdateViewStore.Action>

/// 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.
final class BannerUpdateViewStore: Store {

// MARK: - Store

/// Represents the state of the `BannerUpdateViewStore`
struct State {
/// Stores the state of the nested `BannerDataStore`
let bannerViewState: BannerDataStore.State

/// A working copy of the banner being updated, to be uploaded if the `submit` action is sent.
let workingCopy: Banner

/// Returns true if the network state is not started or finished, false if it's in progress
var dismissable: Bool {
switch bannerViewState.networkState {
case .notStarted, .finished:
return true
case .inProgress:
return false
}
}

/// Returns true if the network state is finished and the result is successful, false otherwise
var success: Bool {
switch bannerViewState.networkState {
case .notStarted, .inProgress:
return false
case .finished(let result):
return (try? result.get()) != nil
}
}

// Returns a `NetworkError` if there is an error in the network state when it's finished, otherwise returns nil
var error: MockBannerNetworkStateController.NetworkState.NetworkError? {
return bannerViewState.networkState.error
}
}

enum Action {
/// Action to update the title of the banner with a given string
case updateTitle(String)

/// Action to dismiss an error
case dismissError

/// Action to submit the updated working copy banner to the network
case submit
}

@Published var state: State
var publishedState: AnyPublisher<State, Never> {
return $state.eraseToAnyPublisher()
}

// MARK: - BannerUpdateViewStore

private let bannerDataStore: any BannerDataStoreType

private let newTitlePublisher = PassthroughSubject<String, Never>()

/// Creates a new `BannerUpdateViewStore`.
/// - Parameter bannerDataStore: The data `Store` responsible for updating the banner on the network and its source of truth in the application.
init(bannerDataStore: any BannerDataStoreType) {
self.bannerDataStore = bannerDataStore

state = State(bannerViewState: bannerDataStore.state, workingCopy: bannerDataStore.state.banner)

bannerDataStore
.publishedState
.combineLatest(newTitlePublisher.map(Banner.init).prepend(state.workingCopy))
.map { bannerState, workingCopy in
State(bannerViewState: bannerState, workingCopy: workingCopy)
}
.assign(to: &$state)
}

// MARK: - Store

func send(_ action: Action) {
switch action {
case .updateTitle(let title):
newTitlePublisher.send(title)
case .submit:
bannerDataStore.send(.uploadBanner(state.workingCopy))
case .dismissError:
bannerDataStore.send(.clearNetworkingState)
}
}

}

extension BannerUpdateViewStoreType {
/// Computed property that creates a binding for the working title
var workingTitle: Binding<String> {
makeBinding(stateKeyPath: \.workingCopy.title, actionCasePath: /Action.updateTitle)
}

/// Computed property that creates a binding for the error presentation state
var isErrorPresented: Binding<Bool> {
.init(get: {
return self.state.error != nil
}, set: { _ in
self.send(.dismissError)
})
}
}
Loading

0 comments on commit 37fb876

Please sign in to comment.