diff --git a/PingSampleApp/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock index c90092f75..d7180ca02 100644 --- a/PingSampleApp/ios/Podfile.lock +++ b/PingSampleApp/ios/Podfile.lock @@ -2607,6 +2607,7 @@ PODS: - hermes-engine - PingDavinci (= 2.1.0) - PingDavinciPlugin (= 2.1.0) + - PingExternalIdP (= 2.1.0) - PingLogger (= 2.1.0) - PingOidc (= 2.1.0) - PingOrchestrate (= 2.1.0) @@ -3615,61 +3616,61 @@ SPEC CHECKSUMS: RCTTypeSafety: d877728097547d0a37786cc9130c43ad71739ac3 React: 4b0b9cb962e694611e5e8a697c1b0300a2510c21 React-callinvoker: 70f125c17c7132811a6b473946ac5e7ae93b5e57 - React-Core: bab40f5b1f46fe0c5896895a6f333e861a821a81 - React-CoreModules: 05647d952e521113c128360633896ba7ba652e82 - React-cxxreact: 2b4bac1ec6eecc6288ac8a6caea6afb42585740e + React-Core: 7cbc3118df2334b2ef597d9a515938b02c82109f + React-CoreModules: 7d8c14ecb889e7786a04637583b55b7d8f246baf + React-cxxreact: f32be07cba236c2f20f4e05ca200577ba5358e78 React-debug: deb3a146ef717fa3e8f4c23e0288369fe53199b7 - React-defaultsnativemodule: 11e2948787a15d3cf1b66d7f29f13770a177bff7 - React-domnativemodule: 2f4b279acdb2963736fb5de2f585811dd90070b5 - React-Fabric: 6f8d1a303c96f1d078c14d74c4005bf457e5b782 - React-FabricComponents: b106410970e9a0c4e592da656c7a7e0947306c23 - React-FabricImage: 1abaf230dfce9b58fdf53c4128f3f40c6e64af6a - React-featureflags: f7ef58d91079efde3ad223bcca6d197e845d5bcf - React-featureflagsnativemodule: ae5abc9849d1696f4f8f11ee3744bf5715e032cf - React-graphics: b306856c6ed9aac32f717a229550406a53b28a6d - React-hermes: b6edce8fa19388654b1aea30844497cbeade83bc - React-idlecallbacksnativemodule: cb386712842cb9e479c89311edb234d529b64db4 - React-ImageManager: 8ce94417853eaa22faaad1f4cc1952dd3f8e2275 - React-jserrorhandler: ab827d67dc270a9c8703eef524230baeafaf6876 - React-jsi: 545342ec5c78ab1277af5f0dbe8d489e7e73db14 - React-jsiexecutor: 20210891c7c77255c16dec6762faf68b373f9f74 - React-jsinspector: 4e73460e488132d70d2b4894e5578cc856f2cb74 - React-jsinspectorcdp: 8b2bcb5779289cb2b9ca517f2965ed23eb2fd3e0 - React-jsinspectornetwork: b5e0cb9e488d294eed2d8209dc3dc0f9587210c1 - React-jsinspectortracing: f3c4036e7b984405ac910f878576d325dd9f2834 - React-jsitooling: 75bbfd221b6173a5e848ca5a6680506bac064a56 - React-jsitracing: 11ed7d821864dd988c159d4943e0a1e0937c11b1 - React-logger: 984ebd897afad067555d081deaf03f57c4315723 - React-Mapbuffer: 0c045c844ce6d85cde53e85ab163294c6adad349 - React-microtasksnativemodule: d9499269ad1f484ae71319bac1d9231447f2094e - react-native-config: adc7d6e70302363192ed8f2eae027864d218ff71 - react-native-safe-area-context: 01f6d357d42395422eed7c3bfe715614728f2989 - React-NativeModulesApple: 983f3483ef0a3446b56d490f09d579fba2442e17 + React-defaultsnativemodule: 2c13a4240c5f96c42d069d1ba2392de6b4145bbd + React-domnativemodule: 91349b0b1cb20310cec1341b87cdd461aaa85e57 + React-Fabric: bdfc7ec2481f26d7a9b8f59461f29ba4d903c549 + React-FabricComponents: 47898469543d1bfb4528a9846419ec5568be89b1 + React-FabricImage: ac8fc85ef452e5e9ae935c41118814651bd9e7f3 + React-featureflags: 793b911e4c53e680db4a7d9965d0d6dc87b2fa88 + React-featureflagsnativemodule: 25c9516d0dd004493c9bbafeb97da20bf9bde7dc + React-graphics: e07281690425dd9eeba3875d1faad28bc1f6da3b + React-hermes: bc1440d0e0662cc813bbf1c5ffbf9e0db2993a0f + React-idlecallbacksnativemodule: a2a3bb4a1793280b34d06d00169153b094be8c16 + React-ImageManager: c9fa7461f3cab08e7bc98cbf55455b499e71c8b3 + React-jserrorhandler: 15e591702040afed99cfcd088cf2337a8d09d807 + React-jsi: 512ab3a1a628bc8824c41de8bcbbb81b2ac6fa8d + React-jsiexecutor: 653ccd2dee1e5ea558eecaf2f27b8bba0f09add8 + React-jsinspector: 9121ccd2676a3f7c079ac01c9f90183422e3190e + React-jsinspectorcdp: 5c723ff2a09d73f2fdc496a545fb7003e7fdc079 + React-jsinspectornetwork: 9cb0173f69e8405cef33fc79030fad26bbc3c073 + React-jsinspectortracing: 65dc04125dc2392d85a82b6916f8cb088ea77566 + React-jsitooling: 21af93cc98f760dd88d65b06b9317e0d4849fbbc + React-jsitracing: 4cc1b7de8087ae41c61a0eeee2593bc3362908b6 + React-logger: 2f0d40bc8e648fbb1ff3b6580ad54189a8753290 + React-Mapbuffer: 9a7c65078c6851397c1999068989e4fc239d0c80 + React-microtasksnativemodule: 4f1ef719ba6c7ebbd2d75346ffa2916f9b4771c9 + react-native-config: 85858ab5e598762e783a43ac592561ad110c35a9 + react-native-safe-area-context: d446989793f96dc2f44c33c42dbfb316d983f24e + React-NativeModulesApple: f6f696e510b9d89c3c06b7764f56947dc13ae922 React-oscompat: 114036cd8f064558c9c1a0c04fc9ae5e1453706a - React-perflogger: e7287fee27c16e3c8bd4d470f2361572b63be16b - React-performancetimeline: 8ebbaa31d2d0cea680b0a2a567500d3cab8954fc + React-perflogger: 4b2f88ae059b600daf268528a4a83366338eef05 + React-performancetimeline: e15fd9798123436f99e46898422fe921fecf506b React-RCTActionSheet: 68c68b0a7a5d2b0cfc255c64889b6e485974e988 - React-RCTAnimation: d6c5c728b888a967ce9aff1ff71a8ed71a68d069 - React-RCTAppDelegate: 0fc048666bda159cd469a6fb9befb04b3fa62be4 - React-RCTBlob: 12d8c699a1f906840113ee8d8bb575e69a05509f - React-RCTFabric: 01e815845ebc185f44205dcbf50eeb712fec23fe - React-RCTFBReactNativeSpec: f57927fb0af6ce2f25c19f8b894e2986138aa89f - React-RCTImage: a82518168f4ee407913b23ca749ca79ef51959f3 - React-RCTLinking: 7f343b584c36f024f390fea563483568fe763ef6 - React-RCTNetwork: 3165eb757ceb62a7cde4cdad043d63314122e8a3 - React-RCTRuntime: feee590c459c4cb6aaa7a00f3abc8c04709b536f - React-RCTSettings: 6bad0ae45d8d872c873059f332f586f99875621f - React-RCTText: 657d60f35983062de8f0cea67c279aa7a3ea9858 - React-RCTVibration: 78f4770515141efb7f55f9b27c49dda95319c3a8 + React-RCTAnimation: 6bf502c89c53076f92cd1a254f5ec8d63ee263de + React-RCTAppDelegate: c90f5732784684c3dd226d812eccb578cd954ad7 + React-RCTBlob: d2905f01749b80efd6d3b86fb15e30ed26d5450b + React-RCTFabric: 435b3ffaad113fb1f274c2f2a677c9fcc9b5cf55 + React-RCTFBReactNativeSpec: a3178b419f42af196e90ca4bf07710dce5d68301 + React-RCTImage: 8f5ffa03461339180a68820ea452af6e20ace2c7 + React-RCTLinking: 1151646834d31f97580d8a75d768a84b2533b7f9 + React-RCTNetwork: 52008724d0db90a540f4058ed0de0e41c4b7943c + React-RCTRuntime: 10ce9a7cb27ba307544d29a2a04e6202dc7b3e9a + React-RCTSettings: f724cacbd892ee18f985e1aebdd97386e49c76f5 + React-RCTText: 6e1b95d9126d808410dfa96e09bc4441ec6f36f7 + React-RCTVibration: 862a4e5b36d49e6299c8cbfb86486fc31f86f6fa React-rendererconsistency: f7baab26c6d0cd5b2eb7afcecfd2d8b957017b18 - React-renderercss: bdd2f83a4a054c3e4321fd61305c202b848e471b - React-rendererdebug: 9f8865ee038127a9d99d4b034c9da4935d204993 + React-renderercss: 62acb8f010a062309e3bd0e203aa14636162e3b3 + React-rendererdebug: 3a89ac44f15c7160735264d585a29525655238d2 React-rncore: f7438473c4c71ee1963fb06a8635bb96013c9e1c - React-RuntimeApple: 4d2ab9f72b9193da86eceded128a67254fc18aeb - React-RuntimeCore: 5fd73030438d094975ca0f549d162dd97746ae38 + React-RuntimeApple: 81f0a9ba81ce7eb203529b0471dc69bf18f5f637 + React-RuntimeCore: 6356e89b2518ba66a989c39a2adb18122a5e3b7b React-runtimeexecutor: 17c70842d5e611130cb66f91e247bc4a609c3508 - React-RuntimeHermes: 3c88e6e1ea7ea0899dcffc77c10d61ea46688cfd - React-runtimescheduler: 024500621c7c93d65371498abb4ee26d34f5d47d + React-RuntimeHermes: 0a1d7ce2fe08cf182235de1a9330b51aa6b935cd + React-runtimescheduler: 10ae98e1417eff159be5df8fdc8fcdaac557aba6 React-timing: c3c923df2b86194e1682e01167717481232f1dc7 React-utils: 7791a96e194eec85cb41dc98a2045b5f07839598 ReactAppDependencyProvider: ba631a31783569c13056dd57ff39e19764abdd6f @@ -3680,7 +3681,7 @@ SPEC CHECKSUMS: RNPingBinding: 1731d5254b7d026df05ee056ab7802f4888537e9 RNPingBrowser: b6afbf0fa46c9ae0a10daeb46927f05eca5c7c6d RNPingCore: 3926e39f9ab60d83fb4d58225303864ca0d8171b - RNPingDavinci: 81122638cb43d20c7e24e66eea5bd8700fd48aae + RNPingDavinci: 605780d605c87d00216376bb2b3f4f5ee72817e8 RNPingDeviceClient: 0ff89a604e97d1168eff3aba4b1d889139619bd7 RNPingDeviceId: 6eb3e9fc68a78e1aee87652e025f6e1ec6d5d67e RNPingDeviceProfile: 3086f14fc5024196a64290afbbf7917432dd5900 @@ -3696,10 +3697,10 @@ SPEC CHECKSUMS: RNSVG: e8fb86f41fccd7b67c4480bb9e179e0ad5785b80 RNVectorIcons: 54df27a2e90ddeb674c7237d76060ec9762d0bc5 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - VisionCamera: e82024ee7ce0fb6168a4c468ef43c711f4486baa - VisionCameraBarcodeScanner: a65d708fd3672e6d43e94cce39b548465851752b + VisionCamera: 3914b29387fc40c64f7022830cfdc604f94eb966 + VisionCameraBarcodeScanner: 0b6fe2e3c52ca68a0dbd5d25719c7523c68d807f Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3 PODFILE CHECKSUM: 35159028c49678100eea2347153c0cf4cd45998f -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/PingSampleApp/src/styles/journeyStyles.ts b/PingSampleApp/src/styles/journeyStyles.ts index 29cdf58b3..e983fa656 100644 --- a/PingSampleApp/src/styles/journeyStyles.ts +++ b/PingSampleApp/src/styles/journeyStyles.ts @@ -97,6 +97,25 @@ export const journeyFullScreenStyles = StyleSheet.create({ color: colors.error, fontSize: 14, }, + nodeHeader: { + color: colors.textDark, + fontSize: 17, + fontWeight: '700', + marginBottom: 4, + }, + nodeDescription: { + color: colors.gray, + fontSize: 14, + lineHeight: 19, + marginBottom: 10, + }, + nodeFooter: { + color: colors.gray, + fontSize: 13, + lineHeight: 18, + marginTop: 8, + fontStyle: 'italic', + }, }); /** @@ -245,6 +264,25 @@ export const journeyClientPanelStyles = StyleSheet.create({ fontWeight: '700', marginBottom: 10, }, + nodeHeader: { + color: colors.textDark, + fontSize: 17, + fontWeight: '700', + marginBottom: 4, + }, + nodeDescription: { + color: colors.gray, + fontSize: 14, + lineHeight: 19, + marginBottom: 10, + }, + nodeFooter: { + color: colors.gray, + fontSize: 13, + lineHeight: 18, + marginTop: 8, + fontStyle: 'italic', + }, topGap: { marginTop: 10, }, diff --git a/PingSampleApp/ui/JourneyFormMinimalScreen.tsx b/PingSampleApp/ui/JourneyFormMinimalScreen.tsx index 15bf81d27..1aa4a1540 100644 --- a/PingSampleApp/ui/JourneyFormMinimalScreen.tsx +++ b/PingSampleApp/ui/JourneyFormMinimalScreen.tsx @@ -11,6 +11,7 @@ import { callbackType } from '@ping-identity/rn-types'; import { useJourney, useJourneyForm } from '@ping-identity/rn-journey'; import { formatError } from './utils/formatError'; import { commonStyles } from '../src/styles/common'; +import { journeyFullScreenStyles as styles } from '../src/styles/journeyStyles'; import JourneyFieldRenderer from './journey/components/molecules/renderers/JourneyFieldRenderer'; import PingTextInput from './components/atoms/PingTextInput'; @@ -98,6 +99,12 @@ export default function JourneyFormMinimalScreen(): React.ReactElement { {node?.type === 'ContinueNode' ? ( <> + {node.header ? ( + {node.header} + ) : null} + {node.description ? ( + {node.description} + ) : null} {form.fields.map(field => ( - Continue + + {node.submitButtonText || 'Continue'} + ) : ( @@ -141,6 +150,10 @@ export default function JourneyFormMinimalScreen(): React.ReactElement { No manual callback input required for this node. )} + + {node.pageFooter ? ( + {node.pageFooter} + ) : null} ) : null} diff --git a/PingSampleApp/ui/JourneyFullScreen.tsx b/PingSampleApp/ui/JourneyFullScreen.tsx index b88d00e90..cefd465d3 100644 --- a/PingSampleApp/ui/JourneyFullScreen.tsx +++ b/PingSampleApp/ui/JourneyFullScreen.tsx @@ -627,6 +627,12 @@ export default function JourneyFullScreen(): React.ReactElement { {node?.type === 'ContinueNode' ? ( + {node.header ? ( + {node.header} + ) : null} + {node.description ? ( + {node.description} + ) : null} Callbacks {indexedCallbacks.map(renderCallback)} {issues.map((issue, index) => ( @@ -639,8 +645,13 @@ export default function JourneyFullScreen(): React.ReactElement { onPress={onSubmit} disabled={loading} > - Continue + + {node.submitButtonText || 'Continue'} + + {node.pageFooter ? ( + {node.pageFooter} + ) : null} ) : null} diff --git a/PingSampleApp/ui/journey/components/organisms/JourneyClientPanel.tsx b/PingSampleApp/ui/journey/components/organisms/JourneyClientPanel.tsx index a1c4c03bf..5e51ea73b 100644 --- a/PingSampleApp/ui/journey/components/organisms/JourneyClientPanel.tsx +++ b/PingSampleApp/ui/journey/components/organisms/JourneyClientPanel.tsx @@ -96,9 +96,13 @@ export default function JourneyClientPanel( - {showCallbackScreen ? ( + {showCallbackScreen && node?.type === 'ContinueNode' ? ( + {header ? {header} : null} + {description ? ( + {description} + ) : null} + {/* `setFieldValue` writes into `useJourneyForm` state, consumed as `form.input` on submit. */} {fields.map(field => ( - Continue + + {submitButtonText || 'Continue'} + ) : null} @@ -308,6 +337,8 @@ export default function JourneyContinuePanel( {pollingWaitSeconds}s. ) : null} + + {pageFooter ? {pageFooter} : null} ); } diff --git a/PingTestRunner/__tests__/integration/journey.test.ts b/PingTestRunner/__tests__/integration/journey.test.ts index 46951d88e..b2d7fe82f 100644 --- a/PingTestRunner/__tests__/integration/journey.test.ts +++ b/PingTestRunner/__tests__/integration/journey.test.ts @@ -168,6 +168,62 @@ describe('@ping-identity/rn-journey — integration', () => { expect(Array.isArray(node.callbacks)).toBe(true); }); + it('start() surfaces ContinueNode UI metadata fields unchanged, including submitButtonText resolved from stage JSON', async () => { + const mock = makeMock({ + start: jest.fn(async () => ({ + id: 'n1', + type: 'ContinueNode', + callbacks: [], + header: 'Sign On', + description: 'Enter your credentials to continue', + stage: JSON.stringify({ + submitButtonText: { en: 'Continue' }, + pageFooter: { en: 'Need help?' }, + }), + submitButtonText: 'Continue', + pageFooter: 'Need help?', + })), + }); + const mod = await loadJourney(mock); + const client = mod.createJourneyClient(VALID_CONFIG); + await client.init(); + const node = await client.start('Login'); + expect(node.header).toBe('Sign On'); + expect(node.description).toBe('Enter your credentials to continue'); + expect(node.stage).toBe( + JSON.stringify({ + submitButtonText: { en: 'Continue' }, + pageFooter: { en: 'Need help?' }, + }), + ); + expect(node.submitButtonText).toBe('Continue'); + expect(node.pageFooter).toBe('Need help?'); + }); + + it('start() surfaces empty-string ContinueNode UI metadata fields when native omits them', async () => { + const mock = makeMock({ + start: jest.fn(async () => ({ + id: 'n1', + type: 'ContinueNode', + callbacks: [], + header: '', + description: '', + stage: '', + submitButtonText: '', + pageFooter: '', + })), + }); + const mod = await loadJourney(mock); + const client = mod.createJourneyClient(VALID_CONFIG); + await client.init(); + const node = await client.start('Login'); + expect(node.header).toBe(''); + expect(node.description).toBe(''); + expect(node.stage).toBe(''); + expect(node.submitButtonText).toBe(''); + expect(node.pageFooter).toBe(''); + }); + it('next() advances the journey and returns the next node', async () => { const mock = makeMock(); const mod = await loadJourney(mock); diff --git a/packages/journey/android/src/main/java/com/pingidentity/rnjourney/mapper/JourneyNodeMapper.kt b/packages/journey/android/src/main/java/com/pingidentity/rnjourney/mapper/JourneyNodeMapper.kt index f9211a201..e4ba67ecf 100644 --- a/packages/journey/android/src/main/java/com/pingidentity/rnjourney/mapper/JourneyNodeMapper.kt +++ b/packages/journey/android/src/main/java/com/pingidentity/rnjourney/mapper/JourneyNodeMapper.kt @@ -30,6 +30,11 @@ import com.pingidentity.journey.callback.TextOutputCallback import com.pingidentity.journey.callback.ValidatedPasswordCallback import com.pingidentity.journey.callback.ValidatedUsernameCallback import com.pingidentity.journey.plugin.callbacks +import com.pingidentity.journey.plugin.description +import com.pingidentity.journey.plugin.header +import com.pingidentity.journey.plugin.pageFooter +import com.pingidentity.journey.plugin.stage +import com.pingidentity.journey.plugin.submitButtonText import com.pingidentity.logger.Logger import com.pingidentity.orchestrate.ContinueNode import com.pingidentity.orchestrate.ErrorNode @@ -51,6 +56,19 @@ internal object JourneyNodeMapper { logger?.w("[$TAG] $message", error) } + /** + * Reads a `ContinueNode` string field, normalizing to "" when the underlying JSON value is + * not a string primitive rather than propagating the native SDK's IllegalArgumentException. + */ + private fun stringFieldOrEmpty(logger: Logger?, field: String, block: () -> String): String { + return try { + block() + } catch (error: IllegalArgumentException) { + logWarning(logger, "Normalizing non-string ContinueNode.$field to \"\"", error) + "" + } + } + /** * Convert a native node into bridge-friendly map payload. * @@ -85,6 +103,20 @@ internal object JourneyNodeMapper { payload["type"] = "ContinueNode" payload["input"] = JsonBridgeMapper.encodeJsonElement(node.input) payload["callbacks"] = node.callbacks.map { mapCallbackPayload(it, logger) } + // TODO-SDK-PARITY(SDKS-5309): ContinueNode.header/.description/.stage throw + // IllegalArgumentException (via JsonElement.jsonPrimitive.content) when the + // server sends a non-string value for that field. Each field is normalized to + // "" independently here to match iOS's `input[...] as? String` fallback, rather + // than rejecting the whole promise. + payload["header"] = stringFieldOrEmpty(logger, "header") { node.header } + payload["description"] = stringFieldOrEmpty(logger, "description") { node.description } + payload["stage"] = stringFieldOrEmpty(logger, "stage") { node.stage } + // TODO-SDK-PARITY(SDKS-5310): submitButtonText/pageFooter locale matching only + // checks Locale.getDefault() here, while iOS iterates the full ordered + // preferred-locale list — identical server data + device settings can resolve + // different localized text per platform. + payload["submitButtonText"] = node.submitButtonText + payload["pageFooter"] = node.pageFooter } is ErrorNode -> { payload["type"] = "ErrorNode" diff --git a/packages/journey/android/src/test/java/com/pingidentity/rnjourney/mapper/JourneyNodeMapperTest.kt b/packages/journey/android/src/test/java/com/pingidentity/rnjourney/mapper/JourneyNodeMapperTest.kt index ba7d0249d..a19f9f97d 100644 --- a/packages/journey/android/src/test/java/com/pingidentity/rnjourney/mapper/JourneyNodeMapperTest.kt +++ b/packages/journey/android/src/test/java/com/pingidentity/rnjourney/mapper/JourneyNodeMapperTest.kt @@ -36,39 +36,239 @@ import com.pingidentity.orchestrate.SuccessNode import com.pingidentity.orchestrate.Workflow import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test +import java.util.Locale /** * Unit tests for Journey node and callback mapping. */ class JourneyNodeMapperTest { + private lateinit var originalLocale: Locale + + /** Saves the JVM default locale so locale-dependent tests can restore it afterward. */ + @Before + fun captureDefaultLocale() { + originalLocale = Locale.getDefault() + } + + /** Restores the JVM default locale captured by [captureDefaultLocale]. */ + @After + fun restoreDefaultLocale() { + Locale.setDefault(originalLocale) + } + + /** + * Builds a real (non-mocked) [ContinueNode] subclass so mapper tests exercise the native + * SDK's own `header`/`description`/`stage`/`submitButtonText`/`pageFooter` extension + * properties, following the pattern established by [mapContinueNodeReturnsCompletePayload]. + */ + private fun continueNode(input: kotlinx.serialization.json.JsonObject) = object : ContinueNode( + context = FlowContext(SharedContext(mutableMapOf("flowId" to "abc-123"))), + workflow = Workflow { }, + input = input, + actions = listOf(object : Action { + override fun toString(): String = "FakeAction" + }) + ) { + override fun asRequest() = throw UnsupportedOperationException("Not used in mapper tests") + } + @Test fun mapContinueNodeReturnsCompletePayload() { - val context = FlowContext(SharedContext(mutableMapOf("flowId" to "abc-123"))) - val workflow = Workflow { } - val action = object : Action { - override fun toString(): String = "FakeAction" - } - val node = object : ContinueNode( - context = context, - workflow = workflow, - input = buildJsonObject { + val node = continueNode( + buildJsonObject { put("stage", "login") - }, - actions = listOf(action) - ) { - override fun asRequest() = throw UnsupportedOperationException("Not used in mapper tests") - } + put("header", "Sign in") + put("description", "Enter your credentials") + } + ) val map = JourneyNodeMapper.mapNodePayload(node) assertEquals("ContinueNode", map["type"]) assertEquals("login", (map["input"] as Map<*, *>)["stage"]) assertTrue((map["callbacks"] as List<*>).isEmpty()) + assertEquals("Sign in", map["header"]) + assertEquals("Enter your credentials", map["description"]) + assertEquals("login", map["stage"]) + // "stage" here is a plain string, not a locale JSON object, so the native SDK's own + // locale lookup finds nothing and falls back to "". + assertEquals("", map["submitButtonText"]) + assertEquals("", map["pageFooter"]) + } + + /** Absent header/description/stage/submitButtonText/pageFooter should all default to "". */ + @Test + fun mapContinueNodeMissingUiFieldsReturnsEmptyStrings() { + val node = continueNode(buildJsonObject { }) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("", map["header"]) + assertEquals("", map["description"]) + assertEquals("", map["stage"]) + assertEquals("", map["submitButtonText"]) + assertEquals("", map["pageFooter"]) + } + + /** submitButtonText/pageFooter should resolve from the stage JSON's locale map. */ + @Test + fun mapContinueNodeResolvesSubmitButtonTextAndPageFooterFromStageJson() { + val stageJson = """{"submitButtonText":{"en-CA":"Continue"},"pageFooter":{"en-CA":"All rights reserved"}}""" + val node = continueNode(buildJsonObject { put("stage", stageJson) }) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals(stageJson, map["stage"]) + assertEquals("Continue", map["submitButtonText"]) + assertEquals("All rights reserved", map["pageFooter"]) + } + + // The single-locale test above passes whether or not locale matching actually works, + // because the native SDK's stage-JSON lookup short-circuits to the only value present when + // the map has exactly one entry. These tests pin Locale.setDefault() to force each distinct + // branch of the matching order (exact, hyphen/underscore variant, language-only, + // first-available) against a stage JSON with multiple locale keys. + /** An exact locale tag match in the stage JSON should win over other candidates. */ + @Test + fun mapContinueNodeResolvesSubmitButtonTextByExactLocaleMatch() { + Locale.setDefault(Locale.forLanguageTag("en-CA")) + val stageJson = """{"submitButtonText":{"en_ca":"Continue CA","fr_fr":"Continuer FR"}}""" + val node = continueNode(buildJsonObject { put("stage", stageJson) }) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("Continue CA", map["submitButtonText"]) + } + + /** A hyphen-cased locale key (e.g. "en-ca") should still match an underscore locale tag. */ + @Test + fun mapContinueNodeResolvesSubmitButtonTextByHyphenVariantMatch() { + Locale.setDefault(Locale.forLanguageTag("en-CA")) + val stageJson = """{"submitButtonText":{"en-ca":"Continue CA","fr-fr":"Continuer FR"}}""" + val node = continueNode(buildJsonObject { put("stage", stageJson) }) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("Continue CA", map["submitButtonText"]) + } + + /** With no region match, resolution should fall back to a language-only locale key. */ + @Test + fun mapContinueNodeResolvesSubmitButtonTextByLanguageOnlyFallback() { + Locale.setDefault(Locale.forLanguageTag("en-GB")) + val stageJson = """{"submitButtonText":{"en":"Continue EN","fr":"Continuer FR"}}""" + val node = continueNode(buildJsonObject { put("stage", stageJson) }) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("Continue EN", map["submitButtonText"]) + } + + /** With no language match at all, resolution should fall back to the first available locale. */ + @Test + fun mapContinueNodeResolvesSubmitButtonTextByFirstAvailableFallback() { + Locale.setDefault(Locale.forLanguageTag("de-DE")) + val stageJson = """{"submitButtonText":{"fr":"Continuer FR","ja":"続く"}}""" + val node = continueNode(buildJsonObject { put("stage", stageJson) }) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("Continuer FR", map["submitButtonText"]) + } + + /** Unparseable stage JSON should not throw; localized fields should default to "". */ + @Test + fun mapContinueNodeWithMalformedStageJsonReturnsEmptyLocalizedFields() { + val node = continueNode(buildJsonObject { put("stage", "not-valid-json") }) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("not-valid-json", map["stage"]) + assertEquals("", map["submitButtonText"]) + assertEquals("", map["pageFooter"]) + } + + /** A non-string header value should normalize to "" rather than propagate an exception. */ + @Test + fun mapContinueNodeWithNonStringHeaderNormalizesToEmptyString() { + // ContinueNode.header throws IllegalArgumentException (via JsonElement.jsonPrimitive.content) + // when the server sends a non-string value. The mapper normalizes it to "" instead of + // rejecting the whole promise, matching iOS's `input[...] as? String` fallback (see + // TODO-SDK-PARITY(SDKS-5309) note in JourneyNodeMapper.kt). + val node = continueNode( + buildJsonObject { + put("header", buildJsonObject { put("nested", "value") }) + put("description", "Enter your credentials") + put("stage", "login") + } + ) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("", map["header"]) + assertEquals("Enter your credentials", map["description"]) + assertEquals("login", map["stage"]) + } + + /** A non-string description value should normalize to "" rather than propagate an exception. */ + @Test + fun mapContinueNodeWithNonStringDescriptionNormalizesToEmptyString() { + val node = continueNode( + buildJsonObject { + put("header", "Sign in") + put("description", buildJsonObject { put("nested", "value") }) + put("stage", "login") + } + ) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("Sign in", map["header"]) + assertEquals("", map["description"]) + assertEquals("login", map["stage"]) + } + + /** A non-string stage value should normalize to "" rather than propagate an exception. */ + @Test + fun mapContinueNodeWithNonStringStageNormalizesToEmptyString() { + val node = continueNode( + buildJsonObject { + put("header", "Sign in") + put("description", "Enter your credentials") + put("stage", buildJsonObject { put("nested", "value") }) + } + ) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("Sign in", map["header"]) + assertEquals("Enter your credentials", map["description"]) + assertEquals("", map["stage"]) + } + + /** header/description/stage should each normalize to "" independently when all are non-string. */ + @Test + fun mapContinueNodeWithAllNonStringUiFieldsNormalizesEachIndependently() { + val node = continueNode( + buildJsonObject { + put("header", buildJsonObject { put("nested", "value") }) + put("description", buildJsonObject { put("nested", "value") }) + put("stage", buildJsonObject { put("nested", "value") }) + } + ) + + val map = JourneyNodeMapper.mapNodePayload(node) + + assertEquals("", map["header"]) + assertEquals("", map["description"]) + assertEquals("", map["stage"]) } @Test diff --git a/packages/journey/ios/Mapper/JourneyNodeMapper.swift b/packages/journey/ios/Mapper/JourneyNodeMapper.swift index c6e3e4929..0862e7436 100644 --- a/packages/journey/ios/Mapper/JourneyNodeMapper.swift +++ b/packages/journey/ios/Mapper/JourneyNodeMapper.swift @@ -38,9 +38,20 @@ enum JourneyNodeMapper { var payload = [String: Any]() switch node { + // TODO-SDK-PARITY(SDKS-5309): a non-`String` `header`/`description`/`stage` value here + // silently falls through to `""` (`as? String` yields `nil`, coalesced), whereas Android's + // equivalent throws `IllegalArgumentException` and rejects the whole promise. case let continueNode as ContinueNode: payload["type"] = "ContinueNode" payload["input"] = JsonBridgeMapper.encodeJsonElement(continueNode.input) ?? NSNull() + payload["header"] = continueNode.pageHeader + payload["description"] = continueNode.pageDescription + payload["stage"] = continueNode.stage + // TODO-SDK-PARITY(SDKS-5310): submitButtonText/pageFooter here iterate the full ordered + // preferred-locale list, while Android only checks Locale.getDefault() — identical server + // data + device settings can resolve different localized text per platform. + payload["submitButtonText"] = continueNode.submitButtonText + payload["pageFooter"] = continueNode.pageFooter payload["callbacks"] = continueNode.callbacks.map { callback in mapCallbackPayload(callback) } diff --git a/packages/journey/ios/Tests/JourneyNodeMapperTests.swift b/packages/journey/ios/Tests/JourneyNodeMapperTests.swift index e35439963..9b3635e82 100644 --- a/packages/journey/ios/Tests/JourneyNodeMapperTests.swift +++ b/packages/journey/ios/Tests/JourneyNodeMapperTests.swift @@ -21,7 +21,12 @@ final class JourneyNodeMapperTests: XCTestCase { let node = TestContinueNode( context: FlowContext(flowContext: SharedContext()), workflow: Workflow(config: WorkflowConfig()), - input: ["authId": "auth-id"], + input: [ + "authId": "auth-id", + "header": "Sign In", + "description": "Enter your credentials", + "stage": "loginStage" + ], actions: [callback] ) @@ -34,6 +39,77 @@ final class JourneyNodeMapperTests: XCTestCase { XCTAssertEqual(callbacks?.count, 1) XCTAssertEqual(callbacks?.first?["type"] as? String, "NameCallback") XCTAssertEqual(callbacks?.first?["prompt"] as? String, "User Name") + XCTAssertEqual(payload["header"] as? String, "Sign In") + XCTAssertEqual(payload["description"] as? String, "Enter your credentials") + XCTAssertEqual(payload["stage"] as? String, "loginStage") + XCTAssertEqual(payload["submitButtonText"] as? String, "") + XCTAssertEqual(payload["pageFooter"] as? String, "") + } + + /// Absent header/description/stage/submitButtonText/pageFooter should all default to "". + func testMapNodePayloadContinueMissingUiFieldsReturnsEmptyStrings() { + let node = TestContinueNode( + context: FlowContext(flowContext: SharedContext()), + workflow: Workflow(config: WorkflowConfig()), + input: [:], + actions: [] + ) + + let payload = JourneyNodeMapper.mapNodePayload(node) + + XCTAssertEqual(payload["header"] as? String, "") + XCTAssertEqual(payload["description"] as? String, "") + XCTAssertEqual(payload["stage"] as? String, "") + XCTAssertEqual(payload["submitButtonText"] as? String, "") + XCTAssertEqual(payload["pageFooter"] as? String, "") + } + + /// submitButtonText/pageFooter should resolve from the stage JSON's locale map. + func testMapNodePayloadContinueResolvesSubmitButtonTextAndPageFooterFromStageJson() { + let node = TestContinueNode( + context: FlowContext(flowContext: SharedContext()), + workflow: Workflow(config: WorkflowConfig()), + input: [ + "stage": "{\"submitButtonText\":{\"en-us\":\"Continue\"},\"pageFooter\":{\"en-us\":\"Footer\"}}" + ], + actions: [] + ) + + let payload = JourneyNodeMapper.mapNodePayload(node) + + XCTAssertEqual(payload["submitButtonText"] as? String, "Continue") + XCTAssertEqual(payload["pageFooter"] as? String, "Footer") + } + + /// Unparseable stage JSON should not throw; localized fields should default to "". + func testMapNodePayloadContinueWithMalformedStageJsonReturnsEmptyLocalizedFields() { + let node = TestContinueNode( + context: FlowContext(flowContext: SharedContext()), + workflow: Workflow(config: WorkflowConfig()), + input: ["stage": "not-json"], + actions: [] + ) + + let payload = JourneyNodeMapper.mapNodePayload(node) + + XCTAssertEqual(payload["stage"] as? String, "not-json") + XCTAssertEqual(payload["submitButtonText"] as? String, "") + XCTAssertEqual(payload["pageFooter"] as? String, "") + } + + /// A non-string header value should resolve to "" rather than propagate an exception. + func testMapNodePayloadContinueWithNonStringHeaderResolvesWithEmptyString() { + let node = TestContinueNode( + context: FlowContext(flowContext: SharedContext()), + workflow: Workflow(config: WorkflowConfig()), + input: ["header": ["nested": "object"]], + actions: [] + ) + + let payload = JourneyNodeMapper.mapNodePayload(node) + + XCTAssertEqual(payload["type"] as? String, "ContinueNode") + XCTAssertEqual(payload["header"] as? String, "") } func testMapNodePayloadErrorIncludesStatusMessageAndInput() { diff --git a/packages/journey/src/types/node.types.ts b/packages/journey/src/types/node.types.ts index 298ff9f8b..085cca22c 100644 --- a/packages/journey/src/types/node.types.ts +++ b/packages/journey/src/types/node.types.ts @@ -69,4 +69,43 @@ export type JourneyNode = Omit & { input?: Record; /** Callback collection when additional user input is required. */ callbacks?: JourneyCallback[]; + /** + * Optional page header text for `ContinueNode` payloads. + * + * @remarks + * Populated only for `ContinueNode`. On iOS this normalizes the native + * `pageHeader` property to this shared field name. + */ + header?: string; + /** + * Optional page description text for `ContinueNode` payloads. + * + * @remarks + * Populated only for `ContinueNode`. On iOS this normalizes the native + * `pageDescription` property to this shared field name. + */ + description?: string; + /** + * Optional raw stage JSON string for `ContinueNode` payloads. + * + * @remarks + * Populated only for `ContinueNode`. + */ + stage?: string; + /** + * Optional locale-resolved submit button text for `ContinueNode` payloads. + * + * @remarks + * Populated only for `ContinueNode`. Resolved by the native SDK from the + * `stage` locale map using the device's preferred locale. + */ + submitButtonText?: string; + /** + * Optional locale-resolved page footer text for `ContinueNode` payloads. + * + * @remarks + * Populated only for `ContinueNode`. Resolved by the native SDK from the + * `stage` locale map using the device's preferred locale. + */ + pageFooter?: string; };