From a337c5b1d6cf7fc7a2fed4a2465330846e4ae27a Mon Sep 17 00:00:00 2001 From: Thais Damasceno Date: Thu, 23 Jul 2026 20:56:52 +0100 Subject: [PATCH 1/2] SDKS-5245:chore: bump android and ios dependencies --- PingTestRunner/android/build.gradle | 2 +- packages/external-idp/android/build.gradle | 2 +- packages/push/RNPingPush.podspec | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/PingTestRunner/android/build.gradle b/PingTestRunner/android/build.gradle index 231b98e5c..50a95a761 100644 --- a/PingTestRunner/android/build.gradle +++ b/PingTestRunner/android/build.gradle @@ -12,7 +12,7 @@ buildscript { compileSdkVersion = 36 targetSdkVersion = 36 ndkVersion = "27.1.12297006" - kotlinVersion = "2.2.10" + kotlinVersion = "2.2.20" } repositories { google() diff --git a/packages/external-idp/android/build.gradle b/packages/external-idp/android/build.gradle index edd60d4df..c1dc14bae 100644 --- a/packages/external-idp/android/build.gradle +++ b/packages/external-idp/android/build.gradle @@ -103,7 +103,7 @@ dependencies { implementation("com.pingidentity.sdks:logger:2.1.0") implementation("com.pingidentity.sdks:external-idp:2.1.0") implementation("com.pingidentity.sdks:journey-plugin:2.1.0") - compileOnly("com.pingidentity.sdks:davinci:2.1.0") + compileOnly("com.pingidentity.sdks:davinci:2.1.0") // IdpCollector is in davinci; compileOnly so apps without rn-davinci don't bundle it implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" implementation(project(":ping-identity_rn-core")) diff --git a/packages/push/RNPingPush.podspec b/packages/push/RNPingPush.podspec index c8a5324c6..dfb37bef2 100644 --- a/packages/push/RNPingPush.podspec +++ b/packages/push/RNPingPush.podspec @@ -43,7 +43,7 @@ Pod::Spec.new do |s| # Native Ping SDK dependencies s.dependency "PingPush", "2.1.0" s.dependency "PingStorage", "2.1.0" - s.dependency "PingLogger", "2.1.0" + s.dependency "PingLogger", "~> 2.1.0" s.dependency "RNPingCore" install_modules_dependencies(s) From 4f459bea8f1bab199f5e63b66692972585161025 Mon Sep 17 00:00:00 2001 From: Gaurav Gill Date: Fri, 14 Aug 2026 11:15:04 -0400 Subject: [PATCH 2/2] feat(rn-davinci): add BooleanCollector and ReadOnlyTextCollector bridge (SDKS-5209) - Map BooleanCollector (inputType BOOLEAN / type SINGLE_CHECKBOX) on Android and iOS, including richContent for T&C hyperlinks - Map ReadOnlyTextCollector (inputType READ_ONLY_TEXT) on Android and iOS with all agreement fields - Add BooleanCollector case to DaVinciCollectorValueApplier on both platforms with asBoolean coercion helper - Extend TypeScript types: BooleanCollector, ReadOnlyTextCollector added to DaVinciCollector union - Update collectorHelpers: boolean field kind, false default value, SINGLE_CHECKBOX in manualCollectorTypes, READ_ONLY_TEXT in outputOnlyCollectorTypes - Add sample app UI: DaVinciBooleanField (checkbox + switch), DaVinciReadOnlyTextField, RichTextLabel with tappable hyperlink support - Add unit tests for mapper and value applier on both Android (Robolectric) and iOS (XCTest) --- PingSampleApp/ios/Podfile.lock | 106 ++++++------- .../molecules/DaVinciBooleanField.tsx | 111 +++++++++++++ .../molecules/DaVinciFieldRenderer.tsx | 6 + .../molecules/DaVinciReadOnlyTextField.tsx | 39 +++++ .../components/molecules/RichTextLabel.tsx | 97 ++++++++++++ PingTestRunner/ios/Podfile.lock | 6 +- packages/davinci/README.md | 2 + .../collector/DaVinciCollectorValueApplier.kt | 12 ++ .../rndavinci/mapper/DaVinciNodeMapper.kt | 54 +++++++ .../DaVinciCollectorValueApplierTest.kt | 42 +++++ .../rndavinci/mapper/DaVinciNodeMapperTest.kt | 128 +++++++++++++++ .../DaVinciCollectorValueApplier.swift | 28 ++++ .../ios/Mapper/DaVinciNodeMapper.swift | 52 ++++++ .../DaVinciCollectorValueApplierTests.swift | 34 ++++ .../ios/Tests/DaVinciNodeMapperTests.swift | 149 +++++++++++++++++- packages/davinci/src/collectorHelpers.ts | 18 ++- packages/davinci/src/types/form.types.ts | 5 +- packages/davinci/src/types/node.types.ts | 76 ++++++++- 18 files changed, 896 insertions(+), 69 deletions(-) create mode 100644 PingSampleApp/ui/davinci/components/molecules/DaVinciBooleanField.tsx create mode 100644 PingSampleApp/ui/davinci/components/molecules/DaVinciReadOnlyTextField.tsx create mode 100644 PingSampleApp/ui/davinci/components/molecules/RichTextLabel.tsx diff --git a/PingSampleApp/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock index c90092f75..5d686fc4a 100644 --- a/PingSampleApp/ios/Podfile.lock +++ b/PingSampleApp/ios/Podfile.lock @@ -3615,61 +3615,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 +3680,7 @@ SPEC CHECKSUMS: RNPingBinding: 1731d5254b7d026df05ee056ab7802f4888537e9 RNPingBrowser: b6afbf0fa46c9ae0a10daeb46927f05eca5c7c6d RNPingCore: 3926e39f9ab60d83fb4d58225303864ca0d8171b - RNPingDavinci: 81122638cb43d20c7e24e66eea5bd8700fd48aae + RNPingDavinci: 605780d605c87d00216376bb2b3f4f5ee72817e8 RNPingDeviceClient: 0ff89a604e97d1168eff3aba4b1d889139619bd7 RNPingDeviceId: 6eb3e9fc68a78e1aee87652e025f6e1ec6d5d67e RNPingDeviceProfile: 3086f14fc5024196a64290afbbf7917432dd5900 @@ -3696,10 +3696,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/ui/davinci/components/molecules/DaVinciBooleanField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciBooleanField.tsx new file mode 100644 index 000000000..d814a0c9e --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciBooleanField.tsx @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import React from 'react'; +import { Pressable, Switch, Text, View } from 'react-native'; +import type { BooleanCollector } from '@ping-identity/rn-davinci'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import RichTextLabel from './RichTextLabel'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a {@link BooleanCollector} as a checkbox row or a switch toggle, + * depending on the collector's `appearance` field. + * + * @param props Renderer props. + * @returns Boolean field element. + */ +export default function DaVinciBooleanField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, value, onChange } = props; + const booleanCollector = collector as BooleanCollector; + const checked = typeof value === 'boolean' ? value : booleanCollector.value; + const showError = + booleanCollector.required && !checked && booleanCollector.errorMessage; + + const label = booleanCollector.required + ? `${booleanCollector.label} *` + : booleanCollector.label; + + if (booleanCollector.appearance === 'SWITCH') { + return ( + + + + onChange(next)} + accessibilityLabel={booleanCollector.label} + /> + + {showError ? ( + + {booleanCollector.errorMessage} + + ) : null} + + ); + } + + return ( + + onChange(!checked)} + accessibilityRole="checkbox" + accessibilityState={{ checked }} + accessibilityLabel={booleanCollector.label} + > + + {checked ? : null} + + + + {showError ? ( + + {booleanCollector.errorMessage} + + ) : null} + + ); +} + +const styles = { + switchRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + }, + switchLabel: { + flex: 1, + marginBottom: 0, + marginRight: 8, + }, + checkboxRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + }, + checkmark: { + color: '#fff', + fontSize: 12, + fontWeight: '700' as const, + }, +}; diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx index 52b086463..cf941e848 100644 --- a/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx @@ -6,6 +6,7 @@ */ import React, { memo } from 'react'; +import DaVinciBooleanField from './DaVinciBooleanField'; import DaVinciDeviceField from './DaVinciDeviceField'; import DaVinciFlowButton from './DaVinciFlowButton'; import DaVinciIdpField from './DaVinciIdpField'; @@ -13,6 +14,7 @@ import DaVinciLabelField from './DaVinciLabelField'; import DaVinciMultiSelectField from './DaVinciMultiSelectField'; import DaVinciPasswordField from './DaVinciPasswordField'; import DaVinciPhoneNumberField from './DaVinciPhoneNumberField'; +import DaVinciReadOnlyTextField from './DaVinciReadOnlyTextField'; import DaVinciSingleSelectField from './DaVinciSingleSelectField'; import DaVinciSubmitButton from './DaVinciSubmitButton'; import DaVinciTextField from './DaVinciTextField'; @@ -42,8 +44,12 @@ function DaVinciFieldRenderer( case 'FLOW_BUTTON': case 'FLOW_LINK': return ; + case 'SINGLE_CHECKBOX': + return ; case 'LABEL': return ; + case 'READ_ONLY_TEXT': + return ; case 'SINGLE_SELECT': case 'DROPDOWN': case 'RADIO': diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciReadOnlyTextField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciReadOnlyTextField.tsx new file mode 100644 index 000000000..c0705abcd --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciReadOnlyTextField.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import React from 'react'; +import { Text, View } from 'react-native'; +import type { ReadOnlyTextCollector } from '@ping-identity/rn-davinci'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a {@link ReadOnlyTextCollector} as a read-only content block, + * with an optional title when `titleEnabled` is `true`. + * + * @param props Renderer props. + * @returns Read-only text element. + */ +export default function DaVinciReadOnlyTextField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector } = props; + const readOnlyCollector = collector as ReadOnlyTextCollector; + + return ( + + {readOnlyCollector.titleEnabled && readOnlyCollector.title ? ( + + {readOnlyCollector.title} + + ) : null} + + {readOnlyCollector.content} + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/RichTextLabel.tsx b/PingSampleApp/ui/davinci/components/molecules/RichTextLabel.tsx new file mode 100644 index 000000000..c070deea8 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/RichTextLabel.tsx @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import React from 'react'; +import { Linking, Text } from 'react-native'; +import type { RichContent } from '@ping-identity/rn-davinci'; +import { colors } from '../../../../src/styles/colors'; + +type Props = { + /** Plain-text fallback when no richContent is provided. */ + text: string; + /** Optional rich content with link replacements. */ + richContent?: RichContent; + style?: object; +}; + +/** + * Renders a string with optional inline hyperlinks from {@link RichContent}. + * + * Splits `richContent.content` on `{{token}}` placeholders and replaces each + * with a tappable `Text` span that opens `replacement.href` via `Linking`. + * Falls back to plain `text` when `richContent` is absent. + */ +export default function RichTextLabel({ + text, + richContent, + style, +}: Props): React.ReactElement { + if (!richContent || !richContent.replacements) { + return {text}; + } + + const parts = splitRichContent(richContent); + + return ( + + {parts.map((part, index) => + part.href ? ( + { + if (part.href) { + Linking.openURL(part.href).catch(() => {}); + } + }} + accessibilityRole="link" + accessibilityLabel={part.value} + > + {part.value} + + ) : ( + {part.value} + ), + )} + + ); +} + +type ContentPart = { value: string; href?: string }; + +function splitRichContent(richContent: RichContent): ContentPart[] { + const { content, replacements } = richContent; + const parts: ContentPart[] = []; + const tokenPattern = /\{\{(\w+)\}\}/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = tokenPattern.exec(content)) !== null) { + if (match.index > lastIndex) { + parts.push({ value: content.slice(lastIndex, match.index) }); + } + const token = match[1]; + const replacement = replacements[token]; + if (replacement) { + parts.push({ value: replacement.value, href: replacement.href }); + } else { + parts.push({ value: match[0] }); + } + lastIndex = match.index + match[0].length; + } + + if (lastIndex < content.length) { + parts.push({ value: content.slice(lastIndex) }); + } + + return parts; +} + +const linkStyle = { + color: colors.primary, + textDecorationLine: 'underline' as const, +}; diff --git a/PingTestRunner/ios/Podfile.lock b/PingTestRunner/ios/Podfile.lock index fb77b2f84..87da52d56 100644 --- a/PingTestRunner/ios/Podfile.lock +++ b/PingTestRunner/ios/Podfile.lock @@ -2404,6 +2404,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) @@ -2440,6 +2441,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) @@ -3588,7 +3590,7 @@ SPEC CHECKSUMS: RNPingBinding: 1731d5254b7d026df05ee056ab7802f4888537e9 RNPingBrowser: b6afbf0fa46c9ae0a10daeb46927f05eca5c7c6d RNPingCore: 3926e39f9ab60d83fb4d58225303864ca0d8171b - RNPingDavinci: 81122638cb43d20c7e24e66eea5bd8700fd48aae + RNPingDavinci: 605780d605c87d00216376bb2b3f4f5ee72817e8 RNPingDeviceClient: 0ff89a604e97d1168eff3aba4b1d889139619bd7 RNPingDeviceId: 6eb3e9fc68a78e1aee87652e025f6e1ec6d5d67e RNPingDeviceProfile: 3086f14fc5024196a64290afbbf7917432dd5900 @@ -3605,4 +3607,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 06fe91365819bf22b320c91bdb09a02aa2b3c437 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/packages/davinci/README.md b/packages/davinci/README.md index 22f9ed43d..cbf74005a 100644 --- a/packages/davinci/README.md +++ b/packages/davinci/README.md @@ -296,7 +296,9 @@ The following collector types are supported on Android and iOS: | `ACTION` | Action button that advances the flow immediately. | Immediate | | `FLOW_BUTTON` | Flow button that advances the flow immediately. | Immediate | | `FLOW_LINK` | Flow link that advances the flow immediately. | Immediate | +| `SINGLE_CHECKBOX` | Single checkbox or toggle (boolean field). | Manual input | | `LABEL` | Read-only display content. | Output-only | +| `READ_ONLY_TEXT` | Read-only text / agreement content. | Output-only | Integration-dependent collectors (for example, social IdP, FIDO, or PingOne Protect) are surfaced in node payloads and require client-side integration before submission diff --git a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/collector/DaVinciCollectorValueApplier.kt b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/collector/DaVinciCollectorValueApplier.kt index 06e947644..2ca5fcaf0 100644 --- a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/collector/DaVinciCollectorValueApplier.kt +++ b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/collector/DaVinciCollectorValueApplier.kt @@ -9,6 +9,7 @@ package com.pingidentity.rndavinci.collector import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType +import com.pingidentity.davinci.collector.BooleanCollector import com.pingidentity.davinci.collector.Device import com.pingidentity.davinci.collector.DeviceAuthenticationCollector import com.pingidentity.davinci.collector.DeviceRegistrationCollector @@ -128,6 +129,9 @@ internal object DaVinciCollectorValueApplier { ) collector.value = device } + is BooleanCollector -> { + collector.value = asBoolean(value, key) + } else -> { // All remaining collectors (TextCollector, PasswordCollector, SingleValueCollector, // SubmitCollector, FlowCollector) extend SingleValueCollector which has setValue(String). @@ -187,6 +191,14 @@ internal object DaVinciCollectorValueApplier { return if (isIntegerValued) truncatedToLong else bridgedNumber } + private fun asBoolean(value: Any?, fieldName: String): Boolean { + return when (value) { + is Boolean -> value + is String -> value.equals("true", ignoreCase = true) + else -> throw IllegalArgumentException("$fieldName expects a boolean value, got: ${value?.javaClass?.simpleName}") + } + } + private fun asString(value: Any?, fieldName: String): String { return when (value) { is String -> value diff --git a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt index ada43075d..b6f156cf8 100644 --- a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt +++ b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt @@ -11,6 +11,7 @@ import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap import kotlinx.serialization.json.Json import com.pingidentity.davinci.collector.Device +import com.pingidentity.davinci.collector.BooleanCollector import com.pingidentity.davinci.collector.DeviceAuthenticationCollector import com.pingidentity.davinci.collector.DeviceRegistrationCollector import com.pingidentity.davinci.collector.FlowCollector @@ -19,6 +20,7 @@ import com.pingidentity.davinci.collector.MultiSelectCollector import com.pingidentity.davinci.collector.PasswordCollector import com.pingidentity.davinci.collector.PasswordPolicy import com.pingidentity.davinci.collector.PhoneNumberCollector +import com.pingidentity.davinci.collector.ReadOnlyTextCollector import com.pingidentity.davinci.collector.SingleSelectCollector import com.pingidentity.davinci.collector.SubmitCollector import com.pingidentity.davinci.collector.TextCollector @@ -198,6 +200,8 @@ internal object DaVinciNodeMapper { is PhoneNumberCollector -> mapPhoneNumberCollector(collector) is DeviceRegistrationCollector -> mapDeviceRegistrationCollector(collector) is DeviceAuthenticationCollector -> mapDeviceAuthenticationCollector(collector) + is BooleanCollector -> mapBooleanCollector(collector) + is ReadOnlyTextCollector -> mapReadOnlyTextCollector(collector) else -> { logWarning( logger, @@ -433,6 +437,56 @@ internal object DaVinciNodeMapper { return map } + /** + * Serialize a [BooleanCollector] to a payload map. + * + * @param collector Native boolean collector. + * @return Serialized boolean collector map. + */ + private fun mapBooleanCollector(collector: BooleanCollector): Map { + val map = baseCollectorMap(collector) + map["value"] = collector.value + map["appearance"] = collector.appearance.value + map["errorMessage"] = collector.errorMessage + collector.richContent?.let { rc -> + map["richContent"] = mapOf( + "content" to rc.content, + "replacements" to rc.replacements.mapValues { (_, r) -> + buildMap { + put("value", r.value) + put("href", r.href) + put("type", r.type) + put("target", r.target) + } + } + ) + } + return map + } + + /** + * Serialize a [ReadOnlyTextCollector] to a payload map. + * + * @remarks + * The `type` field is normalized to `"READ_ONLY_TEXT"` (the stable inputType) rather than the + * server-varying `type` field (e.g. `"AGREEMENT"`), matching the TypeScript contract. + * + * @param collector Native read-only text collector. + * @return Serialized read-only text collector map. + */ + private fun mapReadOnlyTextCollector(collector: ReadOnlyTextCollector): Map { + return linkedMapOf( + "key" to collector.key, + "type" to "READ_ONLY_TEXT", + "content" to collector.content, + "title" to collector.title, + "titleEnabled" to collector.titleEnabled, + "enabled" to collector.enabled, + "agreementId" to collector.agreementId, + "useDynamicAgreement" to collector.useDynamicAgreement + ) + } + private fun mapOptions(options: List): List> { return options.map { option -> mapOf( diff --git a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/collector/DaVinciCollectorValueApplierTest.kt b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/collector/DaVinciCollectorValueApplierTest.kt index cf7f49b0f..c5a284e86 100644 --- a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/collector/DaVinciCollectorValueApplierTest.kt +++ b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/collector/DaVinciCollectorValueApplierTest.kt @@ -9,6 +9,7 @@ package com.pingidentity.rndavinci.collector import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap +import com.pingidentity.davinci.collector.BooleanCollector import com.pingidentity.davinci.collector.DeviceAuthenticationCollector import com.pingidentity.davinci.collector.DeviceRegistrationCollector import com.pingidentity.davinci.collector.FlowCollector @@ -423,6 +424,47 @@ class DaVinciCollectorValueApplierTest { assertEquals("5550001234", collector.phoneNumber) } + @Test + fun applyBooleanCollectorSetsTrueValue() { + val collector = BooleanCollector().apply { + init(buildJsonObject { + put("key", "agree") + put("type", "SINGLE_CHECKBOX") + put("label", "I agree") + put("required", true) + put("inputType", "BOOLEAN") + put("appearance", "CHECKBOX") + put("errorMessage", "Required.") + }) + } + val node = nodeWith(collector) + + DaVinciCollectorValueApplier.apply(node, inputWithCollectors("agree" to true)) + + assertTrue(collector.value) + } + + @Test + fun applyBooleanCollectorSetsFalseValue() { + val collector = BooleanCollector().apply { + init(buildJsonObject { + put("key", "agree") + put("type", "SINGLE_CHECKBOX") + put("label", "I agree") + put("required", true) + put("inputType", "BOOLEAN") + put("appearance", "CHECKBOX") + put("errorMessage", "Required.") + }) + } + val node = nodeWith(collector) + collector.value = true + + DaVinciCollectorValueApplier.apply(node, inputWithCollectors("agree" to false)) + + assertFalse(collector.value) + } + @Test fun applyMultipleCollectorsInSingleInput() { val text = TextCollector().apply { diff --git a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt index 6f09e4e7f..14caa164a 100644 --- a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt +++ b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt @@ -7,6 +7,7 @@ package com.pingidentity.rndavinci.mapper +import com.pingidentity.davinci.collector.BooleanCollector import com.pingidentity.davinci.collector.DeviceAuthenticationCollector import com.pingidentity.davinci.collector.DeviceRegistrationCollector import com.pingidentity.davinci.collector.FlowCollector @@ -14,6 +15,7 @@ import com.pingidentity.davinci.collector.LabelCollector import com.pingidentity.davinci.collector.MultiSelectCollector import com.pingidentity.davinci.collector.PasswordCollector import com.pingidentity.davinci.collector.PhoneNumberCollector +import com.pingidentity.davinci.collector.ReadOnlyTextCollector import com.pingidentity.davinci.collector.SingleSelectCollector import com.pingidentity.davinci.collector.SubmitCollector import com.pingidentity.davinci.collector.TextCollector @@ -694,6 +696,132 @@ class DaVinciNodeMapperTest { assertEquals(href, c["link"]) } + // ---- BooleanCollector ---- + + @Test + fun mapBooleanCollectorIncludesAllBaseAndBooleanFields() { + val collector = BooleanCollector().apply { + init(buildJsonObject { + put("key", "accept-terms") + put("type", "SINGLE_CHECKBOX") + put("label", "I accept the terms") + put("required", true) + put("appearance", "CHECKBOX") + put("errorMessage", "You must accept the terms to continue.") + }) + } + val node = makeNode(collector) + + val result = DaVinciNodeMapper.mapNodePayload(node) + val c = result.asList("collectors")!![0] + + assertEquals("accept-terms", c["key"]) + assertEquals("SINGLE_CHECKBOX", c["type"]) + assertEquals("I accept the terms", c["label"]) + assertEquals(true, c["required"]) + assertEquals(false, c["value"]) + assertEquals("CHECKBOX", c["appearance"]) + assertEquals("You must accept the terms to continue.", c["errorMessage"]) + assertFalse(c.containsKey("richContent")) + } + + @Test + fun mapBooleanCollectorIncludesRichContentWhenPresent() { + val collector = BooleanCollector().apply { + init(buildJsonObject { + put("key", "consent") + put("type", "SINGLE_CHECKBOX") + put("label", "Consent") + put("required", false) + put("appearance", "SWITCH") + put("errorMessage", "") + put("richContent", buildJsonObject { + put("content", "Please read the {{link}} before continuing.") + put("replacements", buildJsonObject { + put("link", buildJsonObject { + put("value", "Terms of Service") + put("href", "https://example.com/tos") + put("type", "link") + put("target", "_blank") + }) + }) + }) + }) + } + val node = makeNode(collector) + + val result = DaVinciNodeMapper.mapNodePayload(node) + val c = result.asList("collectors")!![0] + + assertEquals("SWITCH", c["appearance"]) + @Suppress("UNCHECKED_CAST") + val rc = c["richContent"] as? Map + assertNotNull(rc) + assertEquals("Please read the {{link}} before continuing.", rc!!["content"]) + @Suppress("UNCHECKED_CAST") + val replacements = rc["replacements"] as? Map + assertNotNull(replacements) + @Suppress("UNCHECKED_CAST") + val linkReplacement = replacements!!["link"] as? Map + assertNotNull(linkReplacement) + assertEquals("Terms of Service", linkReplacement!!["value"]) + assertEquals("https://example.com/tos", linkReplacement["href"]) + assertEquals("link", linkReplacement["type"]) + assertEquals("_blank", linkReplacement["target"]) + } + + @Test + fun mapBooleanCollectorOmitsRichContentWhenAbsent() { + val collector = BooleanCollector().apply { + init(buildJsonObject { + put("key", "toggle") + put("type", "SINGLE_CHECKBOX") + put("label", "Toggle") + put("required", false) + }) + } + val node = makeNode(collector) + + val result = DaVinciNodeMapper.mapNodePayload(node) + val c = result.asList("collectors")!![0] + + assertFalse(c.containsKey("richContent")) + } + + // ---- ReadOnlyTextCollector ---- + + @Test + fun mapReadOnlyTextCollectorIncludesAllFields() { + val collector = ReadOnlyTextCollector().apply { + init(buildJsonObject { + put("key", "agreement") + put("type", "AGREEMENT") + put("content", "This is example agreement text.") + put("title", "Terms of Service Agreement") + put("titleEnabled", true) + put("enabled", true) + put("agreement", buildJsonObject { + put("id", "6ff30c9e-cd98-4fe5-85ca-01111ca20702") + put("useDynamicAgreement", false) + }) + }) + } + val node = makeNode(collector) + + val result = DaVinciNodeMapper.mapNodePayload(node) + val c = result.asList("collectors")!![0] + + assertEquals("agreement", c["key"]) + // Bridge normalizes type to READ_ONLY_TEXT regardless of server type ("AGREEMENT") + assertEquals("READ_ONLY_TEXT", c["type"]) + assertEquals("This is example agreement text.", c["content"]) + assertEquals("Terms of Service Agreement", c["title"]) + assertEquals(true, c["titleEnabled"]) + assertEquals(true, c["enabled"]) + assertEquals("6ff30c9e-cd98-4fe5-85ca-01111ca20702", c["agreementId"]) + assertEquals(false, c["useDynamicAgreement"]) + } + @Test fun mapPasswordCollectorOmitsPasswordPolicyWhenNoFormPresent() { val collector = PasswordCollector().apply { diff --git a/packages/davinci/ios/Collector/DaVinciCollectorValueApplier.swift b/packages/davinci/ios/Collector/DaVinciCollectorValueApplier.swift index a08cb9f91..f4a5035c7 100644 --- a/packages/davinci/ios/Collector/DaVinciCollectorValueApplier.swift +++ b/packages/davinci/ios/Collector/DaVinciCollectorValueApplier.swift @@ -143,6 +143,9 @@ enum DaVinciCollectorValueApplier { } authenticationCollector.value = device + case let booleanCollector as BooleanCollector: + booleanCollector.value = try asBoolean(value, key: key) + default: // All remaining collectors (TextCollector, PasswordCollector, SingleSelectCollector, // SubmitCollector, FlowCollector) extend SingleValueCollector which accepts a String @@ -256,6 +259,31 @@ enum DaVinciCollectorValueApplier { } } + /// Coerce a dynamic value to a Bool. + /// + /// - Parameters: + /// - value: Dynamic value. + /// - key: Collector key (used in error messages). + /// - Returns: Bool value. + /// - Throws: `DaVinciBridgeError.argument` when value cannot be represented as a Bool. + private static func asBoolean(_ value: Any?, key: String) throws -> Bool { + switch value { + case let bool as Bool: + return bool + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + return number.intValue != 0 + case let string as String: + return string.lowercased() == "true" + default: + throw DaVinciBridgeError.argument( + "Collector key='\(key)' expects a boolean value" + ) + } + } + /// Coerce a dynamic value to a string-keyed string map. /// /// - Parameter value: Dynamic value. diff --git a/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift b/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift index 2d549dced..55b15af1d 100644 --- a/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift +++ b/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift @@ -221,6 +221,10 @@ enum DaVinciNodeMapper { map = mapDeviceRegistrationCollector(registrationCollector) case let authenticationCollector as DeviceAuthenticationCollector: map = mapDeviceAuthenticationCollector(authenticationCollector) + case let booleanCollector as BooleanCollector: + map = mapBooleanCollector(booleanCollector) + case let readOnlyCollector as ReadOnlyTextCollector: + map = mapReadOnlyTextCollector(readOnlyCollector) default: logger?.w( "[\(logTag)] Skipping unsupported collector type: \(String(describing: type(of: collector)))", @@ -433,6 +437,54 @@ enum DaVinciNodeMapper { return map } + /// Serializes a `BooleanCollector` to a bridge map. + /// + /// - Parameter collector: BooleanCollector instance. + /// - Returns: Serialized boolean collector map. + /// - Note: `errorMessage` is nullable on iOS — emitted as `""` when absent to match the Android non-null contract. + private static func mapBooleanCollector(_ collector: BooleanCollector) -> [String: Any] { + var map = baseFieldCollectorMap(collector) + map["value"] = collector.value + map["appearance"] = collector.appearance.rawValue + map["errorMessage"] = collector.errorMessage ?? "" + if let rc = collector.richContent { + map["richContent"] = [ + "content": rc.content, + "replacements": Dictionary( + uniqueKeysWithValues: rc.replacements.map { (k, r) in + (k, [ + "value": r.value, + "href": r.href ?? "", + "type": r.type, + "target": r.target ?? "" + ] as [String: Any]) + } + ) + ] as [String: Any] + } + return map + } + + /// Serializes a `ReadOnlyTextCollector` to a bridge map. + /// + /// The `type` field is normalized to `"READ_ONLY_TEXT"` (the stable inputType) rather + /// than emitting the server-varying `collector.type` (e.g. `"AGREEMENT"`). + /// + /// - Parameter collector: ReadOnlyTextCollector instance. + /// - Returns: Serialized read-only text collector map. + private static func mapReadOnlyTextCollector(_ collector: ReadOnlyTextCollector) -> [String: Any] { + return [ + "key": collector.key, + "type": "READ_ONLY_TEXT", + "content": collector.content, + "title": collector.title, + "titleEnabled": collector.titleEnabled, + "enabled": collector.enabled, + "agreementId": collector.agreementId, + "useDynamicAgreement": collector.useDynamicAgreement + ] + } + /// Converts an array of `Option` values to serializable bridge maps. /// /// - Parameter options: Native option values from a select collector. diff --git a/packages/davinci/ios/Tests/DaVinciCollectorValueApplierTests.swift b/packages/davinci/ios/Tests/DaVinciCollectorValueApplierTests.swift index 704d8ca9a..5850ac656 100644 --- a/packages/davinci/ios/Tests/DaVinciCollectorValueApplierTests.swift +++ b/packages/davinci/ios/Tests/DaVinciCollectorValueApplierTests.swift @@ -295,6 +295,33 @@ final class DaVinciCollectorValueApplierTests: XCTestCase { } } + // MARK: - apply — BooleanCollector + + func testApplyBooleanCollectorSetsTrueValue() throws { + let collector = makeBooleanCollector(key: "agree") + let node = makeContinueNode(collectors: [collector]) + let mutations = [DaVinciCollectorValueApplier.CollectorMutation(key: "agree", value: true)] + _ = try DaVinciCollectorValueApplier.apply(node, mutations: mutations) + XCTAssertTrue(collector.value) + } + + func testApplyBooleanCollectorSetsFalseValue() throws { + let collector = makeBooleanCollector(key: "agree") + collector.value = true + let node = makeContinueNode(collectors: [collector]) + let mutations = [DaVinciCollectorValueApplier.CollectorMutation(key: "agree", value: false)] + _ = try DaVinciCollectorValueApplier.apply(node, mutations: mutations) + XCTAssertFalse(collector.value) + } + + func testApplyBooleanCollectorCoercesNSNumberBool() throws { + let collector = makeBooleanCollector(key: "agree") + let node = makeContinueNode(collectors: [collector]) + let mutations = [DaVinciCollectorValueApplier.CollectorMutation(key: "agree", value: NSNumber(value: true))] + _ = try DaVinciCollectorValueApplier.apply(node, mutations: mutations) + XCTAssertTrue(collector.value) + } + // MARK: - apply — FlowCollector trigger detection func testApplyReturnsFlowTriggerTrueForFlowCollector() throws { @@ -369,6 +396,13 @@ final class DaVinciCollectorValueApplierTests: XCTestCase { ]) } + private func makeBooleanCollector(key: String) -> BooleanCollector { + return BooleanCollector(with: [ + "key": key, "type": "SINGLE_CHECKBOX", "inputType": "BOOLEAN", + "label": "I agree", "required": true, "appearance": "CHECKBOX", "errorMessage": "Required." + ]) + } + private func makeDevice(type: String, title: String = "Test Device") -> Device { let json: [String: Any] = [ "type": type, diff --git a/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift b/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift index 727be44b1..b8f8b28ea 100644 --- a/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift +++ b/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift @@ -388,13 +388,14 @@ final class DaVinciNodeMapperTests: XCTestCase { // MARK: - unsupportedFields parity func testMapNodePayloadSurfacesUnsupportedFields() { + // BOOLEAN and READ_ONLY_TEXT are registered in 2.1.0 and produce collectors, so they + // no longer appear in unsupportedFields. Use a genuinely unregistered inputType here. let input: [String: Any] = [ "form": [ "components": [ "fields": [ ["key": "username", "type": "TEXT", "inputType": "TEXT"], - ["key": "tos", "type": "AGREEMENT", "inputType": "READ_ONLY_TEXT"], - ["key": "marketing", "type": "SINGLE_CHECKBOX", "inputType": "BOOLEAN"] + ["key": "exotic", "type": "EXOTIC_FUTURE_TYPE", "inputType": "EXOTIC_FUTURE_TYPE"] ] ] ] @@ -407,11 +408,9 @@ final class DaVinciNodeMapperTests: XCTestCase { let payload = DaVinciNodeMapper.mapNodePayload(node) let unsupported = payload["unsupportedFields"] as? [[String: Any]] - XCTAssertEqual(unsupported?.count, 2) - XCTAssertEqual(unsupported?[0]["key"] as? String, "tos") - XCTAssertEqual(unsupported?[0]["type"] as? String, "READ_ONLY_TEXT") - XCTAssertEqual(unsupported?[1]["key"] as? String, "marketing") - XCTAssertEqual(unsupported?[1]["type"] as? String, "BOOLEAN") + XCTAssertEqual(unsupported?.count, 1) + XCTAssertEqual(unsupported?[0]["key"] as? String, "exotic") + XCTAssertEqual(unsupported?[0]["type"] as? String, "EXOTIC_FUTURE_TYPE") } func testMapNodePayloadFallsBackToTypeWhenInputTypeMissing() { @@ -594,6 +593,142 @@ final class DaVinciNodeMapperTests: XCTestCase { XCTAssertNil(first?["link"]) } + // MARK: - BooleanCollector serialization + + func testMapBooleanCollectorIncludesAllBaseAndBooleanFields() { + let node = makeContinueNode(collectors: [ + BooleanCollector(with: [ + "key": "accept-terms", + "type": "SINGLE_CHECKBOX", + "label": "I accept the terms", + "required": true, + "appearance": "CHECKBOX", + "errorMessage": "You must accept the terms to continue." + ]) + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertEqual(first?["key"] as? String, "accept-terms") + XCTAssertEqual(first?["type"] as? String, "SINGLE_CHECKBOX") + XCTAssertEqual(first?["label"] as? String, "I accept the terms") + XCTAssertEqual(first?["required"] as? Bool, true) + XCTAssertEqual(first?["value"] as? Bool, false) + XCTAssertEqual(first?["appearance"] as? String, "CHECKBOX") + XCTAssertEqual(first?["errorMessage"] as? String, "You must accept the terms to continue.") + XCTAssertNil(first?["richContent"]) + } + + func testMapBooleanCollectorErrorMessageFallsBackToEmptyStringWhenAbsent() { + let node = makeContinueNode(collectors: [ + BooleanCollector(with: [ + "key": "toggle", "type": "SINGLE_CHECKBOX", "label": "Toggle", "required": false + ]) + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertEqual(first?["errorMessage"] as? String, "") + } + + func testMapBooleanCollectorIncludesRichContentWhenPresent() { + let node = makeContinueNode(collectors: [ + BooleanCollector(with: [ + "key": "consent", + "type": "SINGLE_CHECKBOX", + "label": "Consent", + "required": false, + "appearance": "SWITCH", + "richContent": [ + "content": "Please read the {{link}} before continuing.", + "replacements": [ + "link": [ + "value": "Terms of Service", + "href": "https://example.com/tos", + "type": "link", + "target": "_blank" + ] + ] + ] + ]) + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + let rc = first?["richContent"] as? [String: Any] + + XCTAssertNotNil(rc) + XCTAssertEqual(rc?["content"] as? String, "Please read the {{link}} before continuing.") + let replacements = rc?["replacements"] as? [String: Any] + let linkReplacement = replacements?["link"] as? [String: Any] + XCTAssertNotNil(linkReplacement) + XCTAssertEqual(linkReplacement?["value"] as? String, "Terms of Service") + XCTAssertEqual(linkReplacement?["href"] as? String, "https://example.com/tos") + XCTAssertEqual(linkReplacement?["type"] as? String, "link") + XCTAssertEqual(linkReplacement?["target"] as? String, "_blank") + } + + func testMapBooleanCollectorOmitsRichContentWhenAbsent() { + let node = makeContinueNode(collectors: [ + BooleanCollector(with: [ + "key": "toggle", "type": "SINGLE_CHECKBOX", "label": "Toggle", "required": false + ]) + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertNil(first?["richContent"]) + } + + // MARK: - ReadOnlyTextCollector serialization + + func testMapReadOnlyTextCollectorIncludesAllFields() { + let node = makeContinueNode(collectors: [ + ReadOnlyTextCollector(with: [ + "key": "agreement", + "type": "AGREEMENT", + "content": "This is example agreement text.", + "title": "Terms of Service Agreement", + "titleEnabled": true, + "enabled": true, + "agreement": [ + "id": "6ff30c9e-cd98-4fe5-85ca-01111ca20702", + "useDynamicAgreement": false + ] + ]) + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertEqual(first?["key"] as? String, "agreement") + // Bridge normalizes type to READ_ONLY_TEXT regardless of server type ("AGREEMENT") + XCTAssertEqual(first?["type"] as? String, "READ_ONLY_TEXT") + XCTAssertEqual(first?["content"] as? String, "This is example agreement text.") + XCTAssertEqual(first?["title"] as? String, "Terms of Service Agreement") + XCTAssertEqual(first?["titleEnabled"] as? Bool, true) + XCTAssertEqual(first?["enabled"] as? Bool, true) + XCTAssertEqual(first?["agreementId"] as? String, "6ff30c9e-cd98-4fe5-85ca-01111ca20702") + XCTAssertEqual(first?["useDynamicAgreement"] as? Bool, false) + } + + func testMapReadOnlyTextCollectorNormalizesTypeToReadOnlyText() { + // The server `type` field varies (e.g. "AGREEMENT") — the bridge must emit "READ_ONLY_TEXT" + let node = makeContinueNode(collectors: [ + ReadOnlyTextCollector(with: [ + "key": "tos", "type": "AGREEMENT", "content": "TOS text", "title": "", "titleEnabled": false, "enabled": true + ]) + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertEqual(first?["type"] as? String, "READ_ONLY_TEXT") + } + // MARK: - multiple collector types func testMapContinueNodeWithMultipleCollectorTypes() { diff --git a/packages/davinci/src/collectorHelpers.ts b/packages/davinci/src/collectorHelpers.ts index 7ace2b47f..2be98cf7d 100644 --- a/packages/davinci/src/collectorHelpers.ts +++ b/packages/davinci/src/collectorHelpers.ts @@ -24,6 +24,7 @@ const manualCollectorTypes = new Set([ 'TEXT', 'PASSWORD', 'PASSWORD_VERIFY', + 'SINGLE_CHECKBOX', 'SINGLE_SELECT', 'DROPDOWN', 'RADIO', @@ -35,7 +36,7 @@ const manualCollectorTypes = new Set([ 'DEVICE_AUTHENTICATION', ]); -const outputOnlyCollectorTypes = new Set(['LABEL']); +const outputOnlyCollectorTypes = new Set(['LABEL', 'READ_ONLY_TEXT']); const immediateCollectorTypes = new Set([ 'SUBMIT_BUTTON', @@ -81,6 +82,7 @@ export const integrationRequiredCollectorTypes = new Set< const textFieldKindTypes = new Set(['TEXT', 'HIDDEN']); const passwordFieldKindTypes = new Set(['PASSWORD', 'PASSWORD_VERIFY']); +const booleanFieldKindTypes = new Set(['SINGLE_CHECKBOX']); const singleSelectFieldKindTypes = new Set([ 'SINGLE_SELECT', 'DROPDOWN', @@ -96,7 +98,7 @@ const deviceFieldKindTypes = new Set([ 'DEVICE_REGISTRATION', 'DEVICE_AUTHENTICATION', ]); -const outputFieldKindTypes = new Set(['LABEL']); +const outputFieldKindTypes = new Set(['LABEL', 'READ_ONLY_TEXT']); const flowFieldKindTypes = new Set([ 'SUBMIT_BUTTON', 'ACTION', @@ -174,6 +176,9 @@ export function resolveFieldKind(type: string): DaVinciFieldKind { if (passwordFieldKindTypes.has(type)) { return 'password'; } + if (booleanFieldKindTypes.has(type)) { + return 'boolean'; + } if (singleSelectFieldKindTypes.has(type)) { return 'singleSelect'; } @@ -265,6 +270,11 @@ function resolveDefaultValue( return undefined; } + if (booleanFieldKindTypes.has(collector.type)) { + const value = (collector as { value?: unknown }).value; + return typeof value === 'boolean' ? value : false; + } + if ( textFieldKindTypes.has(collector.type) || passwordFieldKindTypes.has(collector.type) || @@ -293,6 +303,10 @@ function hasManualValue( return false; } + if (typeof value === 'boolean') { + return value === true; + } + if (typeof value === 'string') { return value.length > 0; } diff --git a/packages/davinci/src/types/form.types.ts b/packages/davinci/src/types/form.types.ts index 6eca86ffc..bd8cf6346 100644 --- a/packages/davinci/src/types/form.types.ts +++ b/packages/davinci/src/types/form.types.ts @@ -51,11 +51,12 @@ export type DaVinciExecutionMode = * Parallels {@link JourneyFieldKind} from the Journey package. * - `text` — single-line text input (TEXT, HIDDEN) * - `password` — masked password input (PASSWORD, PASSWORD_VERIFY) + * - `boolean` — single checkbox or toggle (SINGLE_CHECKBOX) * - `singleSelect` — single-select input (SINGLE_SELECT, DROPDOWN, RADIO) * - `multiSelect` — multi-select input (MULTI_SELECT, COMBOBOX, CHECKBOX) * - `phone` — phone number input (PHONE_NUMBER) * - `device` — device picker (DEVICE_REGISTRATION, DEVICE_AUTHENTICATION) - * - `output` — display-only content (LABEL) + * - `output` — display-only content (LABEL, READ_ONLY_TEXT) * - `flow` — immediate-submit action (SUBMIT_BUTTON, ACTION, FLOW_BUTTON, FLOW_LINK) * - `integration` — handled by an external integration package (future collectors) * - `unknown` — unrecognised type @@ -65,6 +66,7 @@ export type DaVinciExecutionMode = export type DaVinciFieldKind = | 'text' | 'password' + | 'boolean' | 'singleSelect' | 'multiSelect' | 'phone' @@ -121,6 +123,7 @@ export type DaVinciNormalizedCollector = DaVinciCollector & { export type DaVinciFormValue = | string // TEXT, PASSWORD, SINGLE_SELECT, DEVICE_REGISTRATION (type string) | string[] // MULTI_SELECT + | boolean // SINGLE_CHECKBOX | { countryCode: string; phoneNumber: string } // PHONE_NUMBER | { type: string; id?: string; description?: string } // DEVICE_AUTHENTICATION | null; diff --git a/packages/davinci/src/types/node.types.ts b/packages/davinci/src/types/node.types.ts index 25a87b1bd..b0dbf48a9 100644 --- a/packages/davinci/src/types/node.types.ts +++ b/packages/davinci/src/types/node.types.ts @@ -350,6 +350,76 @@ export type DeviceAuthenticationCollector = BaseCollector & { value?: { type: string; id?: string; description?: string }; }; +/** + * Single checkbox or toggle collector. + * + * @remarks + * Corresponds to the native `BOOLEAN` inputType / `SINGLE_CHECKBOX` server type. + * Extends {@link BaseCollector} — has `key`, `label`, and `required`. + * + * When `required` is `true` and `value` is `false`, the native SDK's `validate()` + * emits a validation error using `errorMessage`. JS consumers should surface + * `errorMessage` to the user in that case. + * + * @public + */ +export type BooleanCollector = BaseCollector & { + type: 'SINGLE_CHECKBOX'; + /** Current checked state (mutable — set via `next()`). */ + value: boolean; + /** Render hint — whether to display as a checkbox or a switch toggle. */ + appearance: 'CHECKBOX' | 'SWITCH'; + /** Validation error message shown when `required` is `true` and `value` is `false`. */ + errorMessage: string; + /** + * Optional rich content with template text and named link replacements. + * + * @remarks + * Present when the server includes a `richContent` object in the field definition. + */ + richContent?: RichContent; +}; + +/** + * Read-only text / agreement collector. + * + * @remarks + * Corresponds to the native `READ_ONLY_TEXT` inputType. The server `type` field + * may vary (e.g. `"AGREEMENT"`) — the bridge normalizes it to `'READ_ONLY_TEXT'` + * for a stable discriminant. + * + * Does not extend {@link BaseCollector} — `ReadOnlyTextCollector` does not implement + * `FieldCollector` in the native SDK and therefore has no `label`, `required`, or `value`. + * Has no mutable value and contributes no payload to form submission. + * + * @public + */ +export type ReadOnlyTextCollector = { + /** Unique collector key identifying this field in the form. */ + key: string; + /** Normalized type discriminant — always `'READ_ONLY_TEXT'` regardless of server `type`. */ + type: 'READ_ONLY_TEXT'; + /** The text content to display to the user. */ + content: string; + /** Title shown when `titleEnabled` is `true`. */ + title: string; + /** Whether to display the title above the content. */ + titleEnabled: boolean; + /** Whether this collector is active. */ + enabled: boolean; + /** UUID of the agreement definition. */ + agreementId: string; + /** Whether the agreement content is loaded dynamically. */ + useDynamicAgreement: boolean; + /** + * Raw server-side field JSON from `node.input.form.components.fields[]`. + * + * @remarks + * Populated by the native mapper. + */ + raw?: Record; +}; + /** * Social login / external IdP collector. * @@ -393,10 +463,6 @@ export type IdpCollector = { /** * Discriminated union of all collector types returned by the DaVinci bridge. * - * @remarks - * Collector types `BooleanCollector` and `ReadOnlyTextCollector` are absent from - * the 2.0.1 Android/iOS SDK sources and are therefore not included here. - * * @public */ export type DaVinciCollector = @@ -410,6 +476,8 @@ export type DaVinciCollector = | PhoneNumberCollector | DeviceRegistrationCollector | DeviceAuthenticationCollector + | BooleanCollector + | ReadOnlyTextCollector | IdpCollector; // ---------------------------------------------------------------------------