Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
850e1f3
test: on init emits initial state
nodediggity Jul 26, 2021
415b56b
test: on init with no initial state delivers reducer default state
nodediggity Jul 26, 2021
ba901ac
test: fix snapshot(s)
nodediggity Jul 26, 2021
6db20e2
feat: extend `StateMapper` w/ support for new `Event`
nodediggity Jul 26, 2021
ab685b8
test: on event dispatch notifies mapper of received event
nodediggity Jul 26, 2021
bf882c6
test: on event dispatch delivers current state to mapper
nodediggity Jul 26, 2021
78bb200
refactor: move `StateContainer` to own file in production target
nodediggity Jul 26, 2021
d6c0197
test: on init with no state delivers default state
nodediggity Jul 26, 2021
f0132bf
test: on init with state delivers given state
nodediggity Jul 26, 2021
feeea8c
refactor: move `rootMapper` to own file in production target
nodediggity Jul 26, 2021
ac6920a
feat: create `StateContainer` instance
nodediggity Jul 26, 2021
69309ef
feat: on feed load success dispatches event to store
nodediggity Jul 26, 2021
42e6aba
feat: deliver items using new `FeedLoadedEvent`
nodediggity Jul 26, 2021
90d57a5
refactor: move payload index look up to helper method
nodediggity Jul 26, 2021
caa7c2f
feat: include `OrderedCollections` framework
nodediggity Jul 27, 2021
3a35b24
feat: populate home feed via our new state container
nodediggity Jul 27, 2021
9e659ab
test: on init with no state delivers default state
nodediggity Jul 27, 2021
6d4079f
test: on init with state delivers given state
nodediggity Jul 27, 2021
39ed7b5
test: on feed loaded event maps payload to state
nodediggity Jul 27, 2021
97af859
test: does not update state on unhandled event
nodediggity Jul 27, 2021
62aea17
refactor: move `FeedMapper` to production target
nodediggity Jul 27, 2021
7271862
refactor: move feed state to own sub state type
nodediggity Jul 27, 2021
a9a15f3
feat: memoize feed state mapping with new `createSelector` helper met…
nodediggity Jul 27, 2021
0b2b7a5
refactor: move store selection behaviour to `Publisher` extension
nodediggity Jul 27, 2021
e6f9f8e
feat: on toggle like dispatches event to store
nodediggity Jul 27, 2021
60d4d01
feat: on like interaction event maps payload to state
nodediggity Jul 27, 2021
e0f31e9
feat: remove local state mutation and handle this as a side effect wi…
nodediggity Jul 27, 2021
8e0d59c
test: improve coverage by asserting both adding and removing a like i…
nodediggity Jul 27, 2021
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
65 changes: 64 additions & 1 deletion Yolo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@
archiveVersion = 1;
classes = {
};
objectVersion = 50;
objectVersion = 52;
objects = {

/* Begin PBXBuildFile section */
6A08AA7026AF29CA00BA287C /* StateContainerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */; };
6A08AA7226AF3B9600BA287C /* StateContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7126AF3B9600BA287C /* StateContainer.swift */; };
6A08AA7426AF3F9700BA287C /* RootMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */; };
6A08AA7726AFD04100BA287C /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 6A08AA7626AFD04100BA287C /* OrderedCollections */; };
6A08AA7926AFD28D00BA287C /* FeedMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */; };
6A08AA7B26AFDD4B00BA287C /* Memoize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7A26AFDD4B00BA287C /* Memoize.swift */; };
6A08AA7D26AFDE7800BA287C /* Selector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7C26AFDE7800BA287C /* Selector.swift */; };
6A08AA7F26AFE0D400BA287C /* FeedSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A08AA7E26AFE0D400BA287C /* FeedSelectorTests.swift */; };
6A137528263BEB4D003F0E5D /* Content.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137527263BEB4D003F0E5D /* Content.swift */; };
6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */; };
6A137537263BED60003F0E5D /* ContentResponseMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */; };
Expand Down Expand Up @@ -101,6 +109,13 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainerTests.swift; sourceTree = "<group>"; };
6A08AA7126AF3B9600BA287C /* StateContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateContainer.swift; sourceTree = "<group>"; };
6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootMapperTests.swift; sourceTree = "<group>"; };
6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedMapperTests.swift; sourceTree = "<group>"; };
6A08AA7A26AFDD4B00BA287C /* Memoize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Memoize.swift; sourceTree = "<group>"; };
6A08AA7C26AFDE7800BA287C /* Selector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Selector.swift; sourceTree = "<group>"; };
6A08AA7E26AFE0D400BA287C /* FeedSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSelectorTests.swift; sourceTree = "<group>"; };
6A137527263BEB4D003F0E5D /* Content.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Content.swift; sourceTree = "<group>"; };
6A13752E263BEC40003F0E5D /* ContentResponseMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapper.swift; sourceTree = "<group>"; };
6A137536263BED60003F0E5D /* ContentResponseMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentResponseMapperTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -200,12 +215,24 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6A08AA7726AFD04100BA287C /* OrderedCollections in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
6A08AA6E26AF29B800BA287C /* Helpers */ = {
isa = PBXGroup;
children = (
6A08AA6F26AF29CA00BA287C /* StateContainerTests.swift */,
6A08AA7326AF3F9700BA287C /* RootMapperTests.swift */,
6A08AA7826AFD28D00BA287C /* FeedMapperTests.swift */,
6A08AA7E26AFE0D400BA287C /* FeedSelectorTests.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
6A137525263BEAFD003F0E5D /* Content */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -447,6 +474,7 @@
isa = PBXGroup;
children = (
6A30B9C42639EA1700A65598 /* Info.plist */,
6A08AA6E26AF29B800BA287C /* Helpers */,
6A30B9CE2639EA4C00A65598 /* Modules */,
6A45D7A7263A62B8003EF1C8 /* Scenes */,
6A30B9DC2639EB4300A65598 /* TestHelpers */,
Expand Down Expand Up @@ -616,6 +644,9 @@
6AE78CA7263C42F500E350FB /* Interactions+Toggle.swift */,
6A45D7CE263A78CB003EF1C8 /* Publisher+MainQueue.swift */,
6AE02CAE263B1E310089B39F /* ResourcePresentationAdapter.swift */,
6A08AA7126AF3B9600BA287C /* StateContainer.swift */,
6A08AA7A26AFDD4B00BA287C /* Memoize.swift */,
6A08AA7C26AFDE7800BA287C /* Selector.swift */,
);
path = Helpers;
sourceTree = "<group>";
Expand Down Expand Up @@ -843,6 +874,9 @@
dependencies = (
);
name = Yolo;
packageProductDependencies = (
6A08AA7626AFD04100BA287C /* OrderedCollections */,
);
productName = Yolo;
productReference = 6A79BE792639D918000062A7 /* Yolo.app */;
productType = "com.apple.product-type.application";
Expand Down Expand Up @@ -875,6 +909,9 @@
Base,
);
mainGroup = 6A79BE702639D918000062A7;
packageReferences = (
6A08AA7526AFD04100BA287C /* XCRemoteSwiftPackageReference "swift-collections" */,
);
productRefGroup = 6A79BE7A2639D918000062A7 /* Products */;
projectDirPath = "";
projectRoot = "";
Expand Down Expand Up @@ -915,13 +952,17 @@
files = (
6A8F261F263C5B07008E86F6 /* FeedUIIntegrationTests+LoaderSpy.swift in Sources */,
6A8F2619263C5AD3008E86F6 /* FeedUIIntegrationTests+Assertions.swift in Sources */,
6A08AA7F26AFE0D400BA287C /* FeedSelectorTests.swift in Sources */,
6A45D827263AE77A003EF1C8 /* FeedCardView+TestHelpers.swift in Sources */,
6A8F2606263C5999008E86F6 /* ContentUIIntergrationTests+Assertions.swift in Sources */,
6A8F2623263C5B5B008E86F6 /* UIView+LayoutCycle.swift in Sources */,
6A08AA7926AFD28D00BA287C /* FeedMapperTests.swift in Sources */,
6A30B9E42639EB6B00A65598 /* XCTestCase+Error.swift in Sources */,
6A30B9D32639EA6700A65598 /* URLProtocolStub.swift in Sources */,
6A30B9DE2639EB5800A65598 /* XCTestCase+Data.swift in Sources */,
6A08AA7026AF29CA00BA287C /* StateContainerTests.swift in Sources */,
6A8F260E263C59F8008E86F6 /* ContentView+TestHelpers.swift in Sources */,
6A08AA7426AF3F9700BA287C /* RootMapperTests.swift in Sources */,
6A45D837263AE998003EF1C8 /* ImageResponseMapperTests.swift in Sources */,
6A45D7F0263A85B3003EF1C8 /* FeedSnapshotTests.swift in Sources */,
6AC229EC263C2B3D0061718F /* InteractionResposneMapperTests.swift in Sources */,
Expand Down Expand Up @@ -959,6 +1000,7 @@
6A13752F263BEC40003F0E5D /* ContentResponseMapper.swift in Sources */,
6A137556263BFEF7003F0E5D /* ContentViewController.swift in Sources */,
6AE02CAB263B19DC0089B39F /* ResourcePresenter.swift in Sources */,
6A08AA7B26AFDD4B00BA287C /* Memoize.swift in Sources */,
6A137528263BEB4D003F0E5D /* Content.swift in Sources */,
6A45D814263AE262003EF1C8 /* Combine+Helpers.swift in Sources */,
6AE02CCB263B326E0089B39F /* UITableView+Dequeueing.swift in Sources */,
Expand All @@ -983,6 +1025,7 @@
6AC229E5263C2AE50061718F /* Interactions.swift in Sources */,
6A26214D263BDC7C00956E14 /* CommentPresenter.swift in Sources */,
6AE02CD9263B41810089B39F /* ResourceViewAdapter.swift in Sources */,
6A08AA7D26AFDE7800BA287C /* Selector.swift in Sources */,
6AE78CA8263C42F500E350FB /* Interactions+Toggle.swift in Sources */,
6AC229DE263C2A4E0061718F /* InteractionResposneMapper.swift in Sources */,
6A45D82F263AE911003EF1C8 /* ImageResponseMapper.swift in Sources */,
Expand All @@ -995,6 +1038,7 @@
6A45D7C0263A6E65003EF1C8 /* FeedUIComposer.swift in Sources */,
6A45D7E5263A7C8A003EF1C8 /* FeedCardCellController.swift in Sources */,
6A26215A263BDE7A00956E14 /* CommentCellController.swift in Sources */,
6A08AA7226AF3B9600BA287C /* StateContainer.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -1252,6 +1296,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */

/* Begin XCRemoteSwiftPackageReference section */
6A08AA7526AFD04100BA287C /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.0.4;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
6A08AA7626AFD04100BA287C /* OrderedCollections */ = {
isa = XCSwiftPackageProductDependency;
package = 6A08AA7526AFD04100BA287C /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = OrderedCollections;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 6A79BE712639D918000062A7 /* Project object */;
}
15 changes: 12 additions & 3 deletions Yolo/Application/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {

private lazy var baseURL = URL(string: "https://powerful-wave-91495.herokuapp.com/")!

convenience init(httpClient: HTTPClient) {
private lazy var store: Store = {
Store(state: nil, mapper: rootMapper)
}()

convenience init(httpClient: HTTPClient, store: Store) {
self.init()
self.httpClient = httpClient
self.store = store
}

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
Expand Down Expand Up @@ -81,8 +86,11 @@ private extension SceneDelegate {
return httpClient
.dispatchPublisher(for: request)
.tryMap(FeedResponseMapper.map)
.map { $0.items }
.eraseToAnyPublisher()
.map(\.items)
.handleEvents(receiveOutput: { [store] items in
store.dispatch(FeedLoadedEvent(payload: items))
})
.select(from: store, using: feedSelector)
}

func makeRemoteImageLoader(_ imageURL: URL) -> AnyPublisher<Data, Error> {
Expand Down Expand Up @@ -128,6 +136,7 @@ private extension SceneDelegate {
}

func makeRemoteInteractionService(id: String, interaction: Interaction) -> AnyPublisher<Interactions, Error> {
store.dispatch(LikeInteractionEvent(payload: (id, interaction == .like)))
var request = URLRequest(
url: baseURL
.appendingPathComponent("interactions")
Expand Down
6 changes: 6 additions & 0 deletions Yolo/Helpers/Combine+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ extension HTTPClient {
.eraseToAnyPublisher()
}
}

extension Publisher {
func select(from store: Store, using selector: @escaping (AppState) -> Output) -> AnyPublisher<Output, Failure> {
flatMap { _ in store.state.map(selector).setFailureType(to: Failure.self) }.eraseToAnyPublisher()
}
}
21 changes: 21 additions & 0 deletions Yolo/Helpers/Memoize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// Memoize.swift
// Yolo
//
// Created by Gordon Smith on 27/07/2021.
//

import Foundation

func memoize<T: Hashable, U>(_ fn: @escaping (T) -> U) -> (T) -> U {
var memo = Dictionary<T, U>()

func result(selector: T) -> U {
if let q = memo[selector] { return q }
let r = fn(selector)
memo[selector] = r
return r
}

return result
}
14 changes: 14 additions & 0 deletions Yolo/Helpers/Selector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Selector.swift
// Yolo
//
// Created by Gordon Smith on 27/07/2021.
//

import Foundation

func createSelector<TInput: Hashable, TOutput, T1>(selector1: @escaping (TInput) -> T1, _ combine: @escaping (T1) -> TOutput) -> (TInput) -> TOutput {
memoize { value in
combine(selector1(value))
}
}
101 changes: 101 additions & 0 deletions Yolo/Helpers/StateContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//
// StateContainer.swift
// Yolo
//
// Created by Gordon Smith on 26/07/2021.
//

import Foundation
import Combine
import OrderedCollections

public protocol Event { }

public typealias StateMapper<T> = (_ state: T?, _ event: Event) -> T

typealias Store = StateContainer<AppState>

public final class StateContainer<T> {
private(set) public var state: CurrentValueSubject<T, Never>
private let mapper: StateMapper<T>

public init(state: T?, mapper: @escaping StateMapper<T>) {
if let state = state {
self.state = .init(state)
} else {
self.state = .init(mapper(nil, StateInit()))
}
self.mapper = mapper
}

public func dispatch(_ event: Event) {
let next = mapper(state.value, event)
state.send(next)
}
}

private extension StateContainer {
struct StateInit: Event { }
}

public struct AppState: Hashable {
let feed: FeedState
public init(feed: FeedState = .init()) {
self.feed = feed
}
}

public let rootMapper: StateMapper<AppState> = { state, event in
AppState(
feed: feedMapper(state?.feed, event)
)
}

public struct FeedLoadedEvent: Event {
public let payload: [FeedItem]
public init(payload: [FeedItem]) {
self.payload = payload
}
}

public struct FeedState: Hashable {
public var items: OrderedDictionary<String, FeedItem>
public init(items: OrderedDictionary<String, FeedItem> = [:]) {
self.items = items
}
}

public let feedMapper: StateMapper<FeedState> = { state, event in
var state = state ?? FeedState()

if let event = event as? FeedLoadedEvent {
event.payload.forEach { item in
state.items[item.id] = item
}
return state
}

if let event = event as? LikeInteractionEvent, let item = state.items[event.payload.id] {
state.items[event.payload.id] = event.payload.isLiked ? item.cloneAsLiked() : item.cloneAsUnliked()
return state
}

return state
}

public let stateSelector = { (state: AppState) in state }
public let feedSelector = createSelector(selector1: stateSelector, { state -> [FeedItem] in
let feed = state.feed
return feed.items.reduce([FeedItem]()) { acc, e in
var acc = acc
acc.append(e.value)
return acc
}
})

public struct LikeInteractionEvent: Event {
public let payload: (id: String, isLiked: Bool)
public init(payload: (id: String, isLiked: Bool)) {
self.payload = payload
}
}
Loading