Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
f11ee51
adding initial implementation of shared state and view store
Apr 3, 2023
3ecea74
presenting sheet
Apr 3, 2023
8dda378
attempting a wrapper view store to forward state and actions
Apr 3, 2023
244a3d7
Create new view store for the update screen
Apr 3, 2023
83ca025
Adds in text field and passing store(s) in
Apr 3, 2023
2199f1b
Syncronously submitting change to PSA
Apr 3, 2023
8957a41
fake network and disabling based on network
Apr 3, 2023
80d4e0c
getting async working
Apr 3, 2023
d1bc074
fixing a bug and starting error
Apr 3, 2023
4275b92
style
Apr 3, 2023
6634bc8
newline
Apr 3, 2023
5466121
Moving scoped view store
Apr 3, 2023
0876976
splitting out more view store and ui changes
Apr 3, 2023
dec2a70
adding error handling
Apr 3, 2023
a230a21
Making scoped ViewStores a bit easier
Apr 3, 2023
feff94f
using some publisher
Apr 3, 2023
c322780
Move networking
Apr 3, 2023
bc10c83
moving files
Apr 3, 2023
0ac0ba5
weak self in
Apr 3, 2023
76cdd01
small name change
Apr 3, 2023
7405435
Renaming view store into data store
Apr 3, 2023
368fda1
PSADataStoreType fixing everywhere
Apr 3, 2023
d82a441
Update PSAView.swift
Apr 3, 2023
26b8263
Moving view state and actions to the top and fixing initial spelling
Apr 3, 2023
868c905
initial keypath
Apr 3, 2023
023fb42
PSA helper
Apr 3, 2023
c712d4e
naming
Apr 3, 2023
b684808
making things public
Apr 4, 2023
8f0a253
adding helper method
Apr 5, 2023
cec376c
doc
Apr 5, 2023
5b42934
renaming ViewStore -> Store
Apr 5, 2023
8b5be1c
fixing tests
Apr 5, 2023
b965cd6
updating docs !
Apr 5, 2023
b81aba8
Fix package source location
Twigz Apr 7, 2023
1505d99
Merge branch 'kpa/testing-composibility-shared-state-and-viewstore' o…
Apr 10, 2023
4bb3a2f
renaming data store variable
Apr 11, 2023
5202d15
Merge branch 'main' into kpa/testing-composibility-shared-state-and-v…
Apr 11, 2023
40a7b1a
Fixing errors and also errors
Apr 11, 2023
1068617
Renaming some folders
Apr 11, 2023
25ce79a
Rename psa -> banner
Apr 11, 2023
fc3e00d
Making new directories
Apr 11, 2023
1636e50
Updating action and docs on the actions themselves
Apr 11, 2023
7cd1d26
adding `MARK`
Apr 11, 2023
7a81bd8
Some docs
Apr 11, 2023
b086bee
Docs for BannerDataStore
Apr 11, 2023
ed2995c
docs
Apr 11, 2023
ba0b086
Docs in update view store
Apr 11, 2023
3826ea6
docs
Apr 11, 2023
e59f278
Renaming Network
Apr 11, 2023
62f0a11
moving MockBannerNetworkStateController
Apr 11, 2023
90ef1c2
docs in the fake network controller
Apr 11, 2023
19c4dc7
updating bindings
Apr 11, 2023
c1b64a4
Updating banner update view for style change
Apr 11, 2023
5032a69
Making `pipeActions` public
Apr 11, 2023
c56f771
Update Example/Photos/With Stores/Banner Feature/BannerDataStore.swift
Pearapps Apr 13, 2023
ef6c1ee
Update Example/Photos/With Stores/Banner Feature/Banner.swift
Pearapps Apr 13, 2023
331e554
Update Example/Photos/With Stores/Banner Feature/MockBannerNetworkSta…
Pearapps Apr 13, 2023
b98a5e0
Update Example/Photos/With Stores/Banner Feature/View Level/BannerUpd…
Pearapps Apr 13, 2023
33c6000
Update Example/Photos/With Stores/Banner Feature/View Level/BannerUpd…
Pearapps Apr 13, 2023
c1a7967
Update Example/Photos/With Stores/Banner Feature/View Level/BannerUpd…
Pearapps Apr 13, 2023
56413f4
Update Example/Photos/With Stores/Banner Feature/View Level/BannerVie…
Pearapps Apr 13, 2023
49b90e6
docs
Apr 19, 2023
08daa15
Merge branch 'kpa/testing-composibility-shared-state-and-viewstore' o…
Apr 19, 2023
bdc3040
Adding networking error
May 9, 2023
9d4209a
moving to a computed var
May 9, 2023
b62e428
docs
May 9, 2023
b37a7d6
doc fixes
May 9, 2023
b42b932
docs
May 9, 2023
88ab983
removing extra doc part
May 9, 2023
6e17850
updating docs
May 9, 2023
44f6e3d
remove some unnecessary docs
May 9, 2023
954a907
Update Example/Photos/With Stores/Banner Feature/MockBannerNetworkSta…
Pearapps May 10, 2023
859a989
Update Example/Photos/With Stores/Banner Feature/View Level/BannerUpd…
Pearapps May 10, 2023
6fdb633
Update Example/Photos/With Stores/PhotoListViewStore.swift
Pearapps May 10, 2023
bfa2243
Feedback
May 16, 2023
c8c555b
feedback
May 16, 2023
b245171
feedback !
May 16, 2023
811c7d8
remove doc
May 16, 2023
60be75c
updating some docs
May 16, 2023
88ed1bb
small doc updates
May 16, 2023
d592a26
Update Example/Photos/With Stores/Banner Feature/MockBannerNetworkSta…
Pearapps May 16, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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