Skip to content
Draft
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
3 changes: 3 additions & 0 deletions ios/core/Sources/Player/HeadlessPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public protocol CoreHooks {
/// Fired when the state changes
var state: Hook<BaseFlowState> { get }

/** A hook to access the current flow */
var onStart: Hook<FlowType> { get }

/// Initialize hooks from reference to javascript core player
init(from: JSValue)
}
Expand Down
9 changes: 8 additions & 1 deletion ios/core/Sources/Types/Core/Flow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import JavaScriptCore

/**
A wrapper around the JS Flow in the core player
The Content navigation state machine
*/
public class Flow: CreatedFromJSValue {
/// Typealias for associated type
Expand Down Expand Up @@ -44,11 +45,17 @@ public class Flow: CreatedFromJSValue {
*/
public init(_ value: JSValue) {
self.value = value
hooks = FlowHooks(transition: Hook2(baseValue: value, name: "transition"), afterTransition: Hook(baseValue: value, name: "afterTransition"))
hooks = FlowHooks(
beforeTransition: SyncWaterfallHook2SecondStringJS<NavigationFlowTransitionableState, NavigationFlowTransitionableState, String>(baseValue: value, name: "beforeTransition"),
transition: Hook2(baseValue: value, name: "transition"),
afterTransition: Hook(baseValue: value, name: "afterTransition")
)
}
}

public struct FlowHooks {
/// A chance to manipulate the flow-node used to calculate the given transition used, excludes NavigationFlowEndState
public var beforeTransition: SyncWaterfallHook2SecondStringJS<NavigationFlowTransitionableState, NavigationFlowTransitionableState, String>
/// A hook that fires when transitioning states and giving the old and new states as parameters
public var transition: Hook2<NamedState?, NamedState>

Expand Down
43 changes: 43 additions & 0 deletions ios/core/Sources/Types/Core/FlowType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// FLowType.swift
// PlayerUI
//
// Created by Zhao Xia Wu on 2025-01-30.
//

import Foundation
import JavaScriptCore

/**
A wrapper around the JS FlowType in the core player
The JSON payload for running Player, different from the Flow class which is the Flow Instance which contains the navigation state machine
*/
public class FlowType: CreatedFromJSValue {
/// Typealias for associated type
public typealias T = FlowType

/// The ID of this flow
public var id: String? { value.objectForKeyedSubscript("id")?.toString() }

/// The original data associated with this flow
public var data: [String: Any]? { value.objectForKeyedSubscript("data")?.toObject() as? [String: Any] }

/**
Creates an instance from a JSValue, used for generic construction
- parameters:
- value: The JSValue to construct from
*/
public static func createInstance(value: JSValue) -> FlowType { FlowType(value) }

/// The JSValue that backs this wrapper
public private(set) var value: JSValue

/**
Construct a Flow from a JSValue
- parameters:
- value: The JSValue that is the Flow
*/
public init(_ value: JSValue) {
self.value = value
}
}
50 changes: 50 additions & 0 deletions ios/core/Sources/Types/Hooks/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,53 @@ public class AsyncHook2<T, U>: BaseJSHook where T: CreatedFromJSValue, U: Create
}
}

/**
This class represents an object in the JS runtime that can be tapped into
to receive JS events that has first parameter T and second generatic parameter U (that can be convert from JSValue using toObject) and returns some value R
*/
public class SyncWaterfallHook2JS<T, R, U>: BaseJSHook where T: CreatedFromJSValue, R: CreatedFromJSValue {

public func tap(_ hook: @escaping (T, U) -> R) {
let tapMethod: @convention(block) (JSValue?, JSValue?) -> JSValue? = { value, value2 in
guard let val = value,
let val2 = value2,
let hookValue = T.createInstance(value: val) as? T,
let hookValue2 = val2.toObject() as? U else {
return nil
}

// Call the hook and get R
let returnValue = hook(hookValue, hookValue2)

// convert R to JSValue
return returnValue as? JSValue
}

self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any])
}
}

/**
This class represents an object in the JS runtime that can be tapped into
to receive JS events that has 1 parameter T and returns some value R
*/
public class SyncWaterfallHookJS<T, R>: BaseJSHook where T: CreatedFromJSValue, R: CreatedFromJSValue {

public func tap(_ hook: @escaping (T) -> R) {
let tapMethod: @convention(block) (JSValue?) -> JSValue? = { value in
guard let val = value,
let hookValue = T.createInstance(value: val) as? T
else {
return nil
}

// Call the hook and get return value R
let returnValue = hook(hookValue)

// convert R to JSValue
return returnValue as? JSValue
}

self.hook.invokeMethod("tap", withArguments: [name, JSValue(object: tapMethod, in: context) as Any])
}
}
62 changes: 62 additions & 0 deletions ios/core/Tests/FlowStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ class FlowStateTests: XCTestCase {
func testActionFlowState() {
let player = HeadlessPlayerImpl(plugins: [])
let hitActionNode = expectation(description: "ACTION state hit")

player.hooks?.onStart.tap { flow in
XCTAssertEqual(flow.id, "counter-flow")
}

player.hooks?.flowController.tap({ flowController in
flowController.hooks.flow.tap { flow in
flow.hooks.transition.tap { oldState, _ in
Expand Down Expand Up @@ -98,4 +103,61 @@ class FlowStateTests: XCTestCase {
wait(for: [endStateHit], timeout: 1)

}

func testFlowControllerTransitionHooks() {
let player = HeadlessPlayerImpl(plugins: [])

var pendingTransition: (currentState: NamedState?, transitionValue: String?)?
var completedTransition: (from: NamedState?, to: NamedState?)?

let hitForceBeforeTransition = expectation(description: "BeforeTransition Hit with *")
let hitForceTransition = expectation(description: "transition Hit with *")

player.hooks?.flowController.tap({ flowController in
flowController.hooks.flow.tap { flow in

flow.hooks.beforeTransition.tap { state, transitionValue in
pendingTransition = (currentState: flow.currentState, transitionValue: transitionValue)

XCTAssertEqual(flow.currentState?.name, "VIEW_1")
hitForceBeforeTransition.fulfill()

return state
}

flow.hooks.transition.tap { from, to in
if from?.name == "VIEW_1" && to.name == "END_Done" {
XCTAssertEqual(from?.name, "VIEW_1")
XCTAssertEqual(to.name, "END_Done")

hitForceTransition .fulfill()
}

completedTransition = (from: from, to: to)
}
}
})

player.start(flow: FlowData.flowControllerFlow) { _ in}

XCTAssertNil(pendingTransition)
XCTAssertNil(completedTransition?.from)
XCTAssertEqual(completedTransition?.to?.name, "VIEW_1")

do {
try (player.state as? InProgressState)?.controllers?.flow.transition(with: "*")
} catch let error {
XCTFail("Transition with * failed with \(error)")
}

let pendingFrom = pendingTransition?.currentState?.value as? NavigationFlowTransitionableState

XCTAssertNotNil(pendingFrom)
XCTAssertEqual((pendingFrom as? NavigationFlowViewState)?.ref, "view-1")
XCTAssertEqual(pendingTransition?.transitionValue, "*")
XCTAssertEqual(completedTransition?.from?.name, "VIEW_1")
XCTAssertEqual(completedTransition?.to?.name, "END_Done")

wait(for: [hitForceTransition, hitForceBeforeTransition], timeout: 15)
}
}
53 changes: 53 additions & 0 deletions ios/internal-test-utils/Sources/FlowData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,57 @@ public struct FlowData {
}

"""

public static let flowControllerFlow = """
{
"id": "generated-flow",
"views": [
{
"id": "view-1",
"type": "collection",
"label": {
"asset": {
"id": "title",
"type": "text",
"value": "Collections are used to group assets."
}
},
"values": [
{
"asset": {
"id": "text-1",
"type": "text",
"value": "This is the first item in the collection"
}
},
{
"asset": {
"id": "text-2",
"type": "text",
"value": "This is the second item in the collection"
}
}
]
}
],
"data": {},
"navigation": {
"BEGIN": "FLOW_1",
"FLOW_1": {
"startState": "VIEW_1",
"VIEW_1": {
"state_type": "VIEW",
"ref": "view-1",
"transitions": {
"*": "END_Done"
}
},
"END_Done": {
"state_type": "END",
"outcome": "done"
}
}
}
}
"""
}
3 changes: 3 additions & 0 deletions ios/swiftui/Sources/SwiftUIPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ public struct SwiftUIPlayerHooks: CoreHooks {
/// Provide Transition Animation information for transition views in the same flow
public var transition: SyncBailHook<Void, PlayerViewTransition>

public var onStart: Hook<FlowType>

/// Initialize hooks from reference to javascript core player
public init(from player: JSValue) {
flowController = Hook<FlowController>(baseValue: player, name: "flowController")
Expand All @@ -296,6 +298,7 @@ public struct SwiftUIPlayerHooks: CoreHooks {
state = Hook<BaseFlowState>(baseValue: player, name: "state")
view = SyncWaterfallHook<AnyView>()
transition = SyncBailHook<Void, PlayerViewTransition>()
onStart = Hook<FlowType>(baseValue: player, name: "onStart")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ public class HeadlessHooks: CoreHooks {

public var state: Hook<BaseFlowState>

public var onStart: Hook<FlowType>

required public init(from value: JSValue) {
flowController = Hook(baseValue: value, name: "flowController")
viewController = Hook(baseValue: value, name: "viewController")
dataController = Hook(baseValue: value, name: "dataController")
state = Hook(baseValue: value, name: "state")
onStart = Hook<FlowType>(baseValue: value, name: "onStart")
}
}

Expand Down
3 changes: 3 additions & 0 deletions ios/test-utils-core/Sources/utilities/TestPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,13 @@ public class TestHooks: CoreHooks {

public var state: Hook<BaseFlowState>

public var onStart: Hook<FlowType>

public required init(from player: JSValue) {
flowController = Hook<FlowController>(baseValue: player, name: "flowController")
viewController = Hook<ViewController>(baseValue: player, name: "viewController")
dataController = Hook<DataController>(baseValue: player, name: "dataController")
state = Hook<BaseFlowState>(baseValue: player, name: "state")
onStart = Hook<FlowType>(baseValue: player, name: "onStart")
}
}