-
Notifications
You must be signed in to change notification settings - Fork 0
Shared state composable ViewStore example #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Pearapps
merged 81 commits into
main
from
kpa/testing-composibility-shared-state-and-viewstore
May 17, 2023
Merged
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
3ecea74
presenting sheet
8dda378
attempting a wrapper view store to forward state and actions
244a3d7
Create new view store for the update screen
83ca025
Adds in text field and passing store(s) in
2199f1b
Syncronously submitting change to PSA
8957a41
fake network and disabling based on network
80d4e0c
getting async working
d1bc074
fixing a bug and starting error
4275b92
style
6634bc8
newline
5466121
Moving scoped view store
0876976
splitting out more view store and ui changes
dec2a70
adding error handling
a230a21
Making scoped ViewStores a bit easier
feff94f
using some publisher
c322780
Move networking
bc10c83
moving files
0ac0ba5
weak self in
76cdd01
small name change
7405435
Renaming view store into data store
368fda1
PSADataStoreType fixing everywhere
d82a441
Update PSAView.swift
26b8263
Moving view state and actions to the top and fixing initial spelling
868c905
initial keypath
023fb42
PSA helper
c712d4e
naming
b684808
making things public
8f0a253
adding helper method
cec376c
doc
5b42934
renaming ViewStore -> Store
8b5be1c
fixing tests
b965cd6
updating docs !
b81aba8
Fix package source location
Twigz 1505d99
Merge branch 'kpa/testing-composibility-shared-state-and-viewstore' o…
4bb3a2f
renaming data store variable
5202d15
Merge branch 'main' into kpa/testing-composibility-shared-state-and-v…
40a7b1a
Fixing errors and also errors
1068617
Renaming some folders
25ce79a
Rename psa -> banner
fc3e00d
Making new directories
1636e50
Updating action and docs on the actions themselves
7cd1d26
adding `MARK`
7a81bd8
Some docs
b086bee
Docs for BannerDataStore
ed2995c
docs
ba0b086
Docs in update view store
3826ea6
docs
e59f278
Renaming Network
62f0a11
moving MockBannerNetworkStateController
90ef1c2
docs in the fake network controller
19c4dc7
updating bindings
c1b64a4
Updating banner update view for style change
5032a69
Making `pipeActions` public
c56f771
Update Example/Photos/With Stores/Banner Feature/BannerDataStore.swift
Pearapps ef6c1ee
Update Example/Photos/With Stores/Banner Feature/Banner.swift
Pearapps 331e554
Update Example/Photos/With Stores/Banner Feature/MockBannerNetworkSta…
Pearapps b98a5e0
Update Example/Photos/With Stores/Banner Feature/View Level/BannerUpd…
Pearapps 33c6000
Update Example/Photos/With Stores/Banner Feature/View Level/BannerUpd…
Pearapps c1a7967
Update Example/Photos/With Stores/Banner Feature/View Level/BannerUpd…
Pearapps 56413f4
Update Example/Photos/With Stores/Banner Feature/View Level/BannerVie…
Pearapps 49b90e6
docs
08daa15
Merge branch 'kpa/testing-composibility-shared-state-and-viewstore' o…
bdc3040
Adding networking error
9d4209a
moving to a computed var
b62e428
docs
b37a7d6
doc fixes
b42b932
docs
88ab983
removing extra doc part
6e17850
updating docs
44f6e3d
remove some unnecessary docs
954a907
Update Example/Photos/With Stores/Banner Feature/MockBannerNetworkSta…
Pearapps 859a989
Update Example/Photos/With Stores/Banner Feature/View Level/BannerUpd…
Pearapps 6fdb633
Update Example/Photos/With Stores/PhotoListViewStore.swift
Pearapps bfa2243
Feedback
c8c555b
feedback
b245171
feedback !
811c7d8
remove doc
60be75c
updating some docs
88ed1bb
small doc updates
d592a26
Update Example/Photos/With Stores/Banner Feature/MockBannerNetworkSta…
Pearapps File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
83
Example/Photos/With Stores/Banner Feature/BannerDataStore.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } | ||
| } | ||
|
|
||
| } | ||
97 changes: 97 additions & 0 deletions
97
Example/Photos/With Stores/Banner Feature/MockBannerNetworkStateController.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
|
|
||
| } |
73 changes: 73 additions & 0 deletions
73
Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateView.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
|
|
||
| } | ||
| } |
121 changes: 121 additions & 0 deletions
121
Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateViewStore.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
Pearapps marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /// 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) | ||
Pearapps marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /// 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> { | ||
Pearapps marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| makeBinding(stateKeyPath: \.workingCopy.title, actionCasePath: /Action.updateTitle) | ||
| } | ||
|
|
||
| /// Computed property that creates a binding for the error presentation state | ||
| var isErrorPresented: Binding<Bool> { | ||
| .init(get: { | ||
Pearapps marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return self.state.error != nil | ||
| }, set: { _ in | ||
| self.send(.dismissError) | ||
| }) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.