diff --git a/ios/core/Sources/Player/HeadlessPlayer.swift b/ios/core/Sources/Player/HeadlessPlayer.swift index 07e5e41f6..9076f18ac 100644 --- a/ios/core/Sources/Player/HeadlessPlayer.swift +++ b/ios/core/Sources/Player/HeadlessPlayer.swift @@ -59,6 +59,9 @@ public protocol CoreHooks { /// Fired when the state changes var state: Hook { get } + /** A hook to access the current flow */ + var onStart: Hook { get } + /// Initialize hooks from reference to javascript core player init(from: JSValue) } diff --git a/ios/core/Sources/Types/Core/Flow.swift b/ios/core/Sources/Types/Core/Flow.swift index ae31cb619..b0e534ded 100644 --- a/ios/core/Sources/Types/Core/Flow.swift +++ b/ios/core/Sources/Types/Core/Flow.swift @@ -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 @@ -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(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 /// A hook that fires when transitioning states and giving the old and new states as parameters public var transition: Hook2 diff --git a/ios/core/Sources/Types/Core/FlowType.swift b/ios/core/Sources/Types/Core/FlowType.swift new file mode 100644 index 000000000..d77d8a1db --- /dev/null +++ b/ios/core/Sources/Types/Core/FlowType.swift @@ -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 + } +} diff --git a/ios/core/Sources/Types/Hooks/Hook.swift b/ios/core/Sources/Types/Hooks/Hook.swift index d89319e93..cabadbe3c 100644 --- a/ios/core/Sources/Types/Hooks/Hook.swift +++ b/ios/core/Sources/Types/Hooks/Hook.swift @@ -221,3 +221,53 @@ public class AsyncHook2: 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: 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: 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]) + } +} diff --git a/ios/core/Tests/FlowStateTests.swift b/ios/core/Tests/FlowStateTests.swift index 7121f2058..bdb33c001 100644 --- a/ios/core/Tests/FlowStateTests.swift +++ b/ios/core/Tests/FlowStateTests.swift @@ -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 @@ -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) + } } diff --git a/ios/internal-test-utils/Sources/FlowData.swift b/ios/internal-test-utils/Sources/FlowData.swift index 89cbc3a4c..82e9999cb 100644 --- a/ios/internal-test-utils/Sources/FlowData.swift +++ b/ios/internal-test-utils/Sources/FlowData.swift @@ -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" + } + } + } +} +""" } diff --git a/ios/swiftui/Sources/SwiftUIPlayer.swift b/ios/swiftui/Sources/SwiftUIPlayer.swift index 13bb62ee0..968010362 100644 --- a/ios/swiftui/Sources/SwiftUIPlayer.swift +++ b/ios/swiftui/Sources/SwiftUIPlayer.swift @@ -288,6 +288,8 @@ public struct SwiftUIPlayerHooks: CoreHooks { /// Provide Transition Animation information for transition views in the same flow public var transition: SyncBailHook + public var onStart: Hook + /// Initialize hooks from reference to javascript core player public init(from player: JSValue) { flowController = Hook(baseValue: player, name: "flowController") @@ -296,6 +298,7 @@ public struct SwiftUIPlayerHooks: CoreHooks { state = Hook(baseValue: player, name: "state") view = SyncWaterfallHook() transition = SyncBailHook() + onStart = Hook(baseValue: player, name: "onStart") } } diff --git a/ios/test-utils-core/Sources/utilities/HeadlessPlayerImpl.swift b/ios/test-utils-core/Sources/utilities/HeadlessPlayerImpl.swift index 8c870b82c..fc2e25a68 100644 --- a/ios/test-utils-core/Sources/utilities/HeadlessPlayerImpl.swift +++ b/ios/test-utils-core/Sources/utilities/HeadlessPlayerImpl.swift @@ -33,11 +33,14 @@ public class HeadlessHooks: CoreHooks { public var state: Hook + public var onStart: Hook + 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(baseValue: value, name: "onStart") } } diff --git a/ios/test-utils-core/Sources/utilities/TestPlayer.swift b/ios/test-utils-core/Sources/utilities/TestPlayer.swift index 80b4da123..8bf34c205 100644 --- a/ios/test-utils-core/Sources/utilities/TestPlayer.swift +++ b/ios/test-utils-core/Sources/utilities/TestPlayer.swift @@ -48,10 +48,13 @@ public class TestHooks: CoreHooks { public var state: Hook + public var onStart: Hook + public required init(from player: JSValue) { flowController = Hook(baseValue: player, name: "flowController") viewController = Hook(baseValue: player, name: "viewController") dataController = Hook(baseValue: player, name: "dataController") state = Hook(baseValue: player, name: "state") + onStart = Hook(baseValue: player, name: "onStart") } }