|
1 | 1 | # CacheStore |
2 | 2 |
|
3 | | -*SwiftUI Observable Cache* |
| 3 | +*SwiftUI State Management* |
4 | 4 |
|
5 | 5 | ## What is `CacheStore`? |
6 | 6 |
|
7 | | -`CacheStore` is a SwiftUI framework to help with state. Define keyed values that you can share locally or globally in your projects. `CacheStore` uses [`c`](https://github.com/0xOpenBytes/c), which a simple composition framework. [`c`](https://github.com/0xOpenBytes/c) has the ability to create transformations that are either unidirectional or bidirectional. There is also a cache that values can be set and resolved, which is used in `CacheStore`. |
| 7 | +`CacheStore` is a SwiftUI tate management framework that uses a dictionary as the state. Scoping creates a single source of truth for the parent state. `CacheStore` uses [`c`](https://github.com/0xOpenBytes/c), which a simple composition framework. [`c`](https://github.com/0xOpenBytes/c) has the ability to create transformations that are either unidirectional or bidirectional. |
| 8 | + |
| 9 | +### CacheStore Basic Idea |
| 10 | + |
| 11 | +A `[AnyHashable: Any]` can be used as the single source of truth for an app. Scoping can be done by limiting the known keys. Modification to the scoped value or parent value should be reflected throughout the app. |
8 | 12 |
|
9 | 13 | ## Objects |
10 | 14 | - `CacheStore`: An object that needs defined Keys to get and set values. |
11 | 15 | - `Store`: An object that needs defined Keys, Actions, and Dependencies. (Preferred) |
| 16 | + - `TestStore`: A testable wrapper around `Store` to make it easy to write XCTestCases |
12 | 17 |
|
13 | | -## Store |
| 18 | +### Store |
14 | 19 |
|
15 | | -A `Store` is an object that you send actions to and read state from. Stores use a private `CacheStore` to manage state behind the scenes. All state changes must be defined in a `StoreActionHandler` where the state gets modified depending on an action. |
| 20 | +A `Store` is an object that you send actions to and read state from. Stores use a `CacheStore` to manage state behind the scenes. All state changes must be defined in a `StoreActionHandler` where the state gets modified depending on an action. |
16 | 21 |
|
17 | | -## Basic Store Example |
| 22 | +### TestStore |
18 | 23 |
|
19 | | -Here is a basic `Store` example where this is a Boolean variable called `isOn`. The only way you can modify that variable is be using defined actions for the given store. In this example there is only one action, toggle. |
| 24 | +When creating tests you should use `TestStore` to send and receive actions while making expectations. If any expectation is false it will be reported in a `XCTestCase`. If there are any effects left at the end of the test, there will be a failure as all effects must be completed and all resulting actions handled. `TestStore` uses a FIFO (first in first out) queue to manage the effects. |
20 | 25 |
|
21 | | -```swift |
22 | | -enum StoreKey { |
23 | | - case isOn |
24 | | -} |
| 26 | +## Basic Usage |
25 | 27 |
|
26 | | -enum Action { |
27 | | - case toggle |
28 | | -} |
29 | | - |
30 | | -let actionHandler = StoreActionHandler<StoreKey, Action, Void> { (store: inout CacheStore<StoreKey>, action: Action, _: Void) in |
31 | | - switch action { |
32 | | - case .toggle: |
33 | | - store.update(.isOn, as: Bool.self, updater: { $0?.toggle() }) |
34 | | - } |
35 | | -} |
| 28 | +<details> |
| 29 | + <summary>Store Example</summary> |
36 | 30 |
|
37 | | -let store = Store<StoreKey, Action, Void>( |
38 | | - initialValues: [.isOn: false], |
39 | | - actionHandler: actionHandler, |
40 | | - dependency: () |
41 | | -) |
| 31 | +```swift |
| 32 | +import CacheStore |
| 33 | +import SwiftUI |
42 | 34 |
|
43 | | -try t.assert(store.get(.isOn), isEqualTo: false) |
| 35 | +struct Post: Codable, Hashable { |
| 36 | + var id: Int |
| 37 | + var userId: Int |
| 38 | + var title: String |
| 39 | + var body: String |
| 40 | +} |
44 | 41 |
|
45 | | -store.handle(action: .toggle) |
| 42 | +enum StoreKey { |
| 43 | + case url |
| 44 | + case posts |
| 45 | + case isLoading |
| 46 | +} |
46 | 47 |
|
47 | | -try t.assert(store.get(.isOn), isEqualTo: true) |
48 | | -``` |
| 48 | +enum Action { |
| 49 | + case fetchPosts |
| 50 | + case postsResponse(Result<[Post], Error>) |
| 51 | +} |
49 | 52 |
|
50 | | -## Basic CacheStore Example |
| 53 | +extension String: Error { } |
51 | 54 |
|
52 | | -Here is a simple application that has two files, an `App` file and `ContentView` file. The `App` contains the `StateObject` `CacheStore`. It then adds the `CacheStore` to the global cache using [`c`](https://github.com/0xOpenBytes/c). `ContentView` can then resolve the cache as an `ObservableObject` which can read or write to the cache. The cache can be injected into the `ContentView` directly, see `ContentView_Previews`, or indirectly, see `ContentView`. |
| 55 | +struct Dependency { |
| 56 | + var fetchPosts: (URL) async -> Result<[Post], Error> |
| 57 | +} |
53 | 58 |
|
54 | | -```swift |
55 | | -import c |
56 | | -import CacheStore |
57 | | -import SwiftUI |
| 59 | +extension Dependency { |
| 60 | + static var mock: Dependency { |
| 61 | + Dependency( |
| 62 | + fetchPosts: { _ in |
| 63 | + sleep(1) |
| 64 | + return .success([Post(id: 1, userId: 1, title: "Mock", body: "Post")]) |
| 65 | + } |
| 66 | + ) |
| 67 | + } |
| 68 | + |
| 69 | + static var live: Dependency { |
| 70 | + Dependency { url in |
| 71 | + do { |
| 72 | + let (data, _) = try await URLSession.shared.data(from: url) |
| 73 | + return .success(try JSONDecoder().decode([Post].self, from: data)) |
| 74 | + } catch { |
| 75 | + return .failure(error) |
| 76 | + } |
| 77 | + } |
| 78 | + } |
| 79 | +} |
58 | 80 |
|
59 | | -enum CacheKey: Hashable { |
60 | | - case someValue |
| 81 | +let actionHandler = StoreActionHandler<StoreKey, Action, Dependency> { cacheStore, action, dependency in |
| 82 | + switch action { |
| 83 | + case .fetchPosts: |
| 84 | + struct FetchPostsID: Hashable { } |
| 85 | + |
| 86 | + guard let url = cacheStore.get(.url, as: URL.self) else { |
| 87 | + return ActionEffect(.postsResponse(.failure("Key `.url` was not a URL"))) |
| 88 | + } |
| 89 | + |
| 90 | + cacheStore.set(value: true, forKey: .isLoading) |
| 91 | + |
| 92 | + return ActionEffect(id: FetchPostsID()) { |
| 93 | + .postsResponse(await dependency.fetchPosts(url)) |
| 94 | + } |
| 95 | + |
| 96 | + case let .postsResponse(.success(posts)): |
| 97 | + cacheStore.set(value: false, forKey: .isLoading) |
| 98 | + cacheStore.set(value: posts, forKey: .posts) |
| 99 | + |
| 100 | + case let .postsResponse(.failure(error)): |
| 101 | + cacheStore.set(value: false, forKey: .isLoading) |
| 102 | + } |
| 103 | + |
| 104 | + return .none |
61 | 105 | } |
62 | 106 |
|
63 | | -@main |
64 | | -struct DemoApp: App { |
65 | | - @StateObject var cacheStore = CacheStore<CacheKey>( |
66 | | - initialValues: [.someValue: "🥳"] |
| 107 | +struct ContentView: View { |
| 108 | + @ObservedObject var store: Store<StoreKey, Action, Dependency> = .init( |
| 109 | + initialValues: [ |
| 110 | + .url: URL(string: "https://jsonplaceholder.typicode.com/posts") as Any |
| 111 | + ], |
| 112 | + actionHandler: actionHandler, |
| 113 | + dependency: .live |
67 | 114 | ) |
| 115 | + .debug |
68 | 116 |
|
69 | | - var body: some Scene { |
70 | | - c.set(value: cacheStore, forKey: "CacheStore") |
71 | | - |
72 | | - return WindowGroup { |
73 | | - VStack { |
74 | | - Text("@StateObject value: \(cacheStore.resolve(.someValue) as String)") |
75 | | - ContentView() |
| 117 | + private var isLoading: Bool { |
| 118 | + store.get(.isLoading, as: Bool.self) ?? true |
| 119 | + } |
| 120 | + |
| 121 | + var body: some View { |
| 122 | + if |
| 123 | + !isLoading, |
| 124 | + let posts = store.get(.posts, as: [Post].self) |
| 125 | + { |
| 126 | + List(posts, id: \.self) { post in |
| 127 | + Text(post.title) |
76 | 128 | } |
| 129 | + } else { |
| 130 | + ProgressView() |
| 131 | + .onAppear { |
| 132 | + store.handle(action: .fetchPosts) |
| 133 | + } |
77 | 134 | } |
78 | 135 | } |
79 | 136 | } |
80 | | - |
81 | 137 | ``` |
82 | 138 |
|
83 | | -### ContentView |
| 139 | +</details> |
| 140 | + |
| 141 | +<details> |
| 142 | + <summary>Testing</summary> |
84 | 143 |
|
85 | 144 | ```swift |
86 | | -import c |
87 | 145 | import CacheStore |
88 | | -import SwiftUI |
| 146 | +import XCTest |
| 147 | +@testable import CacheStoreDemo |
89 | 148 |
|
90 | | -struct ContentView: View { |
91 | | - @ObservedObject var cacheStore: CacheStore<CacheKey> = c.resolve("CacheStore") |
92 | | - |
93 | | - var stringValue: String { |
94 | | - cacheStore.resolve(.someValue) |
| 149 | +class CacheStoreDemoTests: XCTestCase { |
| 150 | + override func setUp() { |
| 151 | + TestStoreFailure.handler = XCTFail |
95 | 152 | } |
96 | 153 |
|
97 | | - var body: some View { |
98 | | - VStack { |
99 | | - Text("Current Value: \(stringValue)") |
100 | | - Button("Update Value") { |
101 | | - cacheStore.set(value: ":D", forKey: .someValue) |
102 | | - } |
| 154 | + func testExample_success() throws { |
| 155 | + let store = TestStore( |
| 156 | + initialValues: [ |
| 157 | + .url: URL(string: "https://jsonplaceholder.typicode.com/posts") as Any |
| 158 | + ], |
| 159 | + actionHandler: actionHandler, |
| 160 | + dependency: .mock |
| 161 | + ) |
| 162 | + |
| 163 | + store.send(.fetchPosts) { cacheStore in |
| 164 | + cacheStore.set(value: true, forKey: .isLoading) |
| 165 | + } |
| 166 | + store.send(.fetchPosts) { cacheStore in |
| 167 | + cacheStore.set(value: true, forKey: .isLoading) |
| 168 | + } |
| 169 | + |
| 170 | + let expectedPosts: [Post] = [Post(id: 1, userId: 1, title: "Mock", body: "Post")] |
| 171 | + |
| 172 | + store.receive(.postsResponse(.success(expectedPosts))) { cacheStore in |
| 173 | + cacheStore.set(value: false, forKey: .isLoading) |
| 174 | + cacheStore.set(value: expectedPosts, forKey: .posts) |
103 | 175 | } |
104 | | - .padding() |
105 | 176 | } |
106 | | -} |
107 | | - |
108 | | -struct ContentView_Previews: PreviewProvider { |
109 | | - static var previews: some View { |
110 | | - ContentView( |
111 | | - cacheStore: CacheStore( |
112 | | - initialValues: [.someValue: "Preview Cache Value"] |
113 | | - ) |
| 177 | + |
| 178 | + func testExample_failure() throws { |
| 179 | + let store = TestStore( |
| 180 | + initialValues: [ |
| 181 | + : |
| 182 | + ], |
| 183 | + actionHandler: actionHandler, |
| 184 | + dependency: .mock |
114 | 185 | ) |
| 186 | + |
| 187 | + store.send(.fetchPosts, expecting: { _ in }) |
| 188 | + |
| 189 | + store.receive(.postsResponse(.failure("Key `.url` was not a URL"))) { cacheStore in |
| 190 | + cacheStore.set(value: false, forKey: .isLoading) |
| 191 | + } |
115 | 192 | } |
116 | 193 | } |
117 | | - |
118 | 194 | ``` |
| 195 | + |
| 196 | +</details> |
| 197 | + |
| 198 | +*** |
| 199 | + |
| 200 | +## Acknowledgement of Dependencies |
| 201 | +- [pointfreeco/swift-custom-dump](https://github.com/pointfreeco/swift-custom-dump) |
| 202 | + |
| 203 | + |
| 204 | +## Inspiration |
| 205 | +- [pointfreeco/swift-composable-architecture](https://github.com/pointfreeco/swift-composable-architecture) |
0 commit comments