Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion Sources/RecombinePackage/Action.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
public enum ActionStrata<Raw, Refined> {
public enum ActionStrata<RawAction, RefinedAction> {
public typealias Raw = RawAction
public typealias Refined = RefinedAction
case raw(Raw)
case refined(Refined)
}
7 changes: 4 additions & 3 deletions Sources/RecombinePackage/Middleware.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import Combine

/// Middleware is a structure that allows you to modify, filter out and create more
/// actions, before the action being handled reaches the store.
/// Middleware is a dependency injection structure that allows you to transform raw actions into refined ones,
/// Refined actions produced by Middleware are then forwarded to the main reducer.
///
public struct Middleware<State, Input, Output> {
public typealias StatePublisher = Publishers.First<Published<State>.Publisher>
public typealias Function = (StatePublisher, Input) -> AnyPublisher<Output, Never>
public typealias Transform<Result> = (StatePublisher, Output) -> Result
internal let transform: Function

/// Create a blank slate Middleware.
/// Create a passthrough Middleware.
public init() where Input == Output {
self.transform = { Just($1).eraseToAnyPublisher() }
}
Expand Down
11 changes: 6 additions & 5 deletions Sources/RecombinePackage/Store/AnyStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Combine

public class AnyStore<BaseState, SubState, RawAction, BaseRefinedAction, SubRefinedAction>: StoreProtocol {
public let underlying: BaseStore<BaseState, RawAction, BaseRefinedAction>
public let keyPath: KeyPath<BaseState, SubState>
public let stateLens: (BaseState) -> SubState
public let actionPromotion: (SubRefinedAction) -> BaseRefinedAction
private var cancellables = Set<AnyCancellable>()
@Published
Expand All @@ -17,7 +17,7 @@ public class AnyStore<BaseState, SubState, RawAction, BaseRefinedAction, SubRefi
Store.SubRefinedAction == SubRefinedAction
{
underlying = store.underlying
keyPath = store.keyPath
stateLens = store.stateLens
actionPromotion = store.actionPromotion
self.state = store.state
store.statePublisher.sink { [unowned self] state in
Expand All @@ -27,7 +27,7 @@ public class AnyStore<BaseState, SubState, RawAction, BaseRefinedAction, SubRefi
}

public func lensing<NewState, NewAction>(
state keyPath: KeyPath<SubState, NewState>,
state lens: @escaping (SubState) -> NewState,
actions transform: @escaping (NewAction) -> SubRefinedAction
) -> LensedStore<
BaseState,
Expand All @@ -36,9 +36,10 @@ public class AnyStore<BaseState, SubState, RawAction, BaseRefinedAction, SubRefi
BaseRefinedAction,
NewAction
> {
.init(
let stateLens = self.stateLens
return .init(
store: underlying,
lensing: self.keyPath.appending(path: keyPath),
lensing: { lens(stateLens($0)) },
actionPromotion: { self.actionPromotion(transform($0)) }
)
}
Expand Down
7 changes: 4 additions & 3 deletions Sources/RecombinePackage/Store/BaseStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class BaseStore<State, RawAction, RefinedAction>: StoreProtocol {
public private(set) var state: State
public var statePublisher: Published<State>.Publisher { $state }
public var underlying: BaseStore<State, RawAction, RefinedAction> { self }
public let keyPath: KeyPath<State, State> = \.self
public let stateLens: (State) -> State = { $0 }
public let rawActions = PassthroughSubject<RawAction, Never>()
public let refinedActions = PassthroughSubject<RefinedAction, Never>()
public let actionPromotion: (RefinedAction) -> RefinedAction = { $0 }
Expand Down Expand Up @@ -44,6 +44,7 @@ public class BaseStore<State, RawAction, RefinedAction>: StoreProtocol {
action: action
)
}
.removeDuplicates(by: stateEquality)
.receive(on: scheduler)
.sink { [unowned self] state in
self.state = state
Expand All @@ -67,7 +68,7 @@ public class BaseStore<State, RawAction, RefinedAction>: StoreProtocol {
}

public func lensing<NewState, NewAction>(
state keyPath: KeyPath<SubState, NewState>,
state lens: @escaping (SubState) -> NewState,
actions transform: @escaping (NewAction) -> SubRefinedAction
) -> LensedStore<
State,
Expand All @@ -76,7 +77,7 @@ public class BaseStore<State, RawAction, RefinedAction>: StoreProtocol {
RefinedAction,
NewAction
> {
.init(store: self, lensing: keyPath, actionPromotion: transform)
.init(store: self, lensing: lens, actionPromotion: transform)
}

open func dispatch<S: Sequence>(refined actions: S) where S.Element == RefinedAction {
Expand Down
23 changes: 12 additions & 11 deletions Sources/RecombinePackage/Store/LensedStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@ public class LensedStore<BaseState, SubState: Equatable, RawAction, BaseRefinedA
public private(set) var state: SubState
public var statePublisher: Published<SubState>.Publisher { $state }
public let underlying: BaseStore<BaseState, RawAction, BaseRefinedAction>
public let keyPath: KeyPath<BaseState, SubState>
public let stateLens: (BaseState) -> SubState
public let actions = PassthroughSubject<SubRefinedAction, Never>()
public let actionPromotion: (SubRefinedAction) -> BaseRefinedAction
private var cancellables = Set<AnyCancellable>()

public required init(store: StoreType, lensing keyPath: KeyPath<BaseState, SubState>, actionPromotion: @escaping (SubRefinedAction) -> BaseRefinedAction) {
public required init(store: StoreType, lensing lens: @escaping (BaseState) -> SubState, actionPromotion: @escaping (SubRefinedAction) -> BaseRefinedAction) {
self.underlying = store
self.keyPath = keyPath
self.stateLens = lens
self.actionPromotion = actionPromotion
state = store.state[keyPath: keyPath]
state = lens(store.state)
store.$state
.map { $0[keyPath: keyPath] }
.map(lens)
.removeDuplicates()
.sink { [unowned self] state in
self.state = state
Expand All @@ -31,7 +31,7 @@ public class LensedStore<BaseState, SubState: Equatable, RawAction, BaseRefinedA
}

public func lensing<NewState, NewAction>(
state keyPath: KeyPath<SubState, NewState>,
state lens: @escaping (SubState) -> NewState,
actions transform: @escaping (NewAction) -> SubRefinedAction
) -> LensedStore<
BaseState,
Expand All @@ -40,9 +40,10 @@ public class LensedStore<BaseState, SubState: Equatable, RawAction, BaseRefinedA
BaseRefinedAction,
NewAction
> {
.init(
let stateLens = self.stateLens
return .init(
store: underlying,
lensing: self.keyPath.appending(path: keyPath),
lensing: { lens(stateLens($0)) },
actionPromotion: { self.actionPromotion(transform($0)) }
)
}
Expand All @@ -56,8 +57,8 @@ public class LensedStore<BaseState, SubState: Equatable, RawAction, BaseRefinedA
}
}

extension LensedStore where BaseRefinedAction == SubRefinedAction {
convenience init(store: StoreType, lensing keyPath: KeyPath<BaseState, SubState>) {
self.init(store: store, lensing: keyPath, actionPromotion: { $0 })
public extension LensedStore where BaseRefinedAction == SubRefinedAction {
convenience init(store: StoreType, lensing lens: @escaping (BaseState) -> SubState) {
self.init(store: store, lensing: lens, actionPromotion: { $0 })
}
}
106 changes: 93 additions & 13 deletions Sources/RecombinePackage/Store/StoreProtocol.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Combine
import SwiftUI

public protocol StoreProtocol: ObservableObject, Subscriber {
associatedtype BaseState
Expand All @@ -9,12 +10,12 @@ public protocol StoreProtocol: ObservableObject, Subscriber {
var state: SubState { get }
var statePublisher: Published<SubState>.Publisher { get }
var underlying: BaseStore<BaseState, RawAction, BaseRefinedAction> { get }
var keyPath: KeyPath<BaseState, SubState> { get }
var stateLens: (BaseState) -> SubState { get }
var actionPromotion: (SubRefinedAction) -> BaseRefinedAction { get }
func dispatch<S: Sequence>(raw: S) where S.Element == RawAction
func dispatch<S: Sequence>(refined: S) where S.Element == SubRefinedAction
func lensing<NewState, NewAction>(
state keyPath: KeyPath<SubState, NewState>,
state lens: @escaping (SubState) -> NewState,
actions transform: @escaping (NewAction) -> SubRefinedAction
) -> LensedStore<
BaseState,
Expand All @@ -27,30 +28,109 @@ public protocol StoreProtocol: ObservableObject, Subscriber {
}

public extension StoreProtocol {
func lensing<NewState, NewAction>(
actions transform: @escaping (NewAction) -> SubRefinedAction
func lensing<NewState>(
state lens: @escaping (SubState) -> NewState
) -> LensedStore<
BaseState,
NewState,
RawAction,
BaseRefinedAction,
SubRefinedAction
> {
lensing(state: lens, actions: { $0 })
}

func lensing<NewState>(
state keyPath: KeyPath<SubState, NewState>
) -> LensedStore<
BaseState,
NewState,
RawAction,
BaseRefinedAction,
SubRefinedAction
> {
lensing(state: { $0[keyPath: keyPath] })
}

func lensing<NewAction>(
actions transform: @escaping (NewAction) -> SubRefinedAction
) -> LensedStore<
BaseState,
SubState,
RawAction,
BaseRefinedAction,
NewAction
> where NewState == SubState {
lensing(state: \.self, actions: transform)
> {
lensing(state: { $0 }, actions: transform)
}

func lensing<NewState, NewAction>(
state keyPath: KeyPath<SubState, NewState>
state keyPath: KeyPath<SubState, NewState>,
actions transform: @escaping (NewAction) -> SubRefinedAction
) -> LensedStore<
BaseState,
NewState,
RawAction,
BaseRefinedAction,
NewAction
> where NewAction == SubRefinedAction {
lensing(state: keyPath, actions: { $0 })
> {
lensing(state: { $0[keyPath: keyPath] }, actions: transform)
}
}

public extension StoreProtocol {
/// Create a SwiftUI Binding from a lensing function and a `SubRefinedAction`.
/// - Parameters:
/// - lens: A lens to the state property.
/// - action: The refined action which will be called when the value is changed.
/// - Returns: A `Binding` whose getter is the property and whose setter dispatches the refined action.
func binding<Value>(
state lens: @escaping (SubState) -> Value,
actions transform: @escaping (Value) -> SubRefinedAction
) -> Binding<Value> {
.init(
get: { lens(self.state) },
set: { self.dispatch(refined: transform($0)) }
)
}

/// Create a SwiftUI Binding from the `SubState` of the store and a `SubRefinedAction`.
/// - Parameters:
/// - actions: The refined action which will be called when the value is changed.
/// - Returns: A `Binding` whose getter is the state and whose setter dispatches the refined action.
func binding(
actions transform: @escaping (SubState) -> SubRefinedAction
) -> Binding<SubState> {
.init(
get: { self.state },
set: { self.dispatch(refined: transform($0)) }
)
}

/// Create a SwiftUI Binding from a lensing function when the value of that function is equivalent to `SubRefinedAction`.
/// - Parameters:
/// - lens: A lens to the state property.
/// - Returns: A `Binding` whose getter is the property and whose setter dispatches the store's refined action.
func binding<Value>(
state lens: @escaping (SubState) -> Value
) -> Binding<Value> where SubRefinedAction == Value {
.init(
get: { lens(self.state) },
set: { self.dispatch(refined: $0) }
)
}

/// Create a SwiftUI Binding from the `SubState` when its value is equivalent to `SubRefinedAction`.
/// - Returns: A `Binding` whose getter is the state and whose setter dispatches the store's refined action.
func binding() -> Binding<SubState> where SubRefinedAction == SubState {
.init(
get: { self.state },
set: { self.dispatch(refined: $0) }
)
}
}

public extension StoreProtocol {
func dispatch(refined actions: SubRefinedAction...) {
dispatch(refined: actions)
}
Expand All @@ -64,12 +144,12 @@ public extension StoreProtocol {
}
}

extension StoreProtocol {
public func receive(subscription: Subscription) {
public extension StoreProtocol {
func receive(subscription: Subscription) {
subscription.request(.unlimited)
}

public func receive(_ input: ActionStrata<RawAction, SubRefinedAction>) -> Subscribers.Demand {
func receive(_ input: ActionStrata<RawAction, SubRefinedAction>) -> Subscribers.Demand {
switch input {
case let .raw(action):
dispatch(raw: action)
Expand All @@ -79,5 +159,5 @@ extension StoreProtocol {
return .unlimited
}

public func receive(completion: Subscribers.Completion<Never>) {}
func receive(completion: Subscribers.Completion<Never>) {}
}
42 changes: 41 additions & 1 deletion Tests/RecombineTests/StoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ class StoreTests: XCTestCase {
middleware: .init(),
publishOn: ImmediateScheduler.shared
)
let subStore = store.lensing(state: \.subState.value, actions: TestFakes.NestedTest.Action.sub)
let subStore = store.lensing(
state: \.subState.value,
actions: TestFakes.NestedTest.Action.sub
)
let stateRecorder = subStore.$state.dropFirst().record()
let actionsRecorder = subStore.actions.record()

Expand All @@ -43,6 +46,43 @@ class StoreTests: XCTestCase {
let actions = try wait(for: actionsRecorder.prefix(1), timeout: 1)
XCTAssertEqual(actions, [.set(string)])
}

func testBinding() throws {
let store = BaseStore(
state: TestFakes.NestedTest.State(),
reducer: TestFakes.NestedTest.reducer,
middleware: .init(),
publishOn: ImmediateScheduler.shared
)
let binding1 = store.binding(
state: \.subState.value,
actions: { .sub(.set("\($0)1")) }
)
let binding2 = store.lensing(
state: \.subState.value
).binding(
actions: { .sub(.set("\($0)2")) }
)
let binding3 = store.lensing(
state: \.subState,
actions: { .sub(.set("\($0)3")) }
).binding(
state: \.value
)
let stateRecorder = store.$state.dropFirst().record()

let string = "Oh Yeah!"

binding1.wrappedValue = string
binding2.wrappedValue = string
binding3.wrappedValue = string

let state = try wait(for: stateRecorder.prefix(3), timeout: 1)
XCTAssertEqual(
state.map(\.subState.value),
zip(repeatElement(string, count: 3), 1...).map { "\($0)\($1)" }
)
}
}

// Used for deinitialization test
Expand Down