diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz
index d160bd8c6..1c08c80d5 100644
Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ
diff --git a/PingSampleApp/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock
index 550a069e3..de6edbef4 100644
--- a/PingSampleApp/ios/Podfile.lock
+++ b/PingSampleApp/ios/Podfile.lock
@@ -228,6 +228,11 @@ PODS:
- PingBrowser (~> 2.1.0)
- PingCommons (~> 2.1.0)
- PingOrchestrate (~> 2.1.0)
+ - PingOneProtect (2.1.0):
+ - PingDavinciPlugin (~> 2.1.0)
+ - PingJourneyPlugin (~> 2.1.0)
+ - PingOneSignals (~> 5.4.0)
+ - PingOneSignals (5.4.0)
- PingOrchestrate (2.1.0):
- PingNetwork (~> 2.1.0)
- PingStorage (~> 2.1.0)
@@ -2925,6 +2930,37 @@ PODS:
- RNPingCore
- SocketRocket
- Yoga
+ - RNPingProtect (1.0.0):
+ - boost
+ - DoubleConversion
+ - fast_float
+ - fmt
+ - glog
+ - hermes-engine
+ - PingOneProtect (= 2.1.0)
+ - RCT-Folly
+ - RCT-Folly/Fabric
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-hermes
+ - React-ImageManager
+ - React-jsi
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-renderercss
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - RNPingCore
+ - SocketRocket
+ - Yoga
- RNPingPush (1.0.0):
- boost
- DoubleConversion
@@ -3302,6 +3338,7 @@ DEPENDENCIES:
- "RNPingLogger (from `../../node_modules/@ping-identity/rn-logger`)"
- "RNPingOath (from `../../node_modules/@ping-identity/rn-oath`)"
- "RNPingOidc (from `../../node_modules/@ping-identity/rn-oidc`)"
+ - "RNPingProtect (from `../../node_modules/@ping-identity/rn-protect`)"
- "RNPingPush (from `../../node_modules/@ping-identity/rn-push`)"
- "RNPingStorage (from `../../node_modules/@ping-identity/rn-storage`)"
- RNScreens (from `../../node_modules/react-native-screens`)
@@ -3351,6 +3388,8 @@ SPEC REPOS:
- PingNetwork
- PingOath
- PingOidc
+ - PingOneProtect
+ - PingOneSignals
- PingOrchestrate
- PingPush
- PingStorage
@@ -3540,6 +3579,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@ping-identity/rn-oath"
RNPingOidc:
:path: "../../node_modules/@ping-identity/rn-oidc"
+ RNPingProtect:
+ :path: "../../node_modules/@ping-identity/rn-protect"
RNPingPush:
:path: "../../node_modules/@ping-identity/rn-push"
RNPingStorage:
@@ -3604,6 +3645,8 @@ SPEC CHECKSUMS:
PingNetwork: 97b4a783caca3ff9382ecbfe2b6da1cff10ed647
PingOath: 34d6c81f32c87a4f568fceb48b531cd1be3e9464
PingOidc: e6d28c4d8c130691872d6aa3b12a1ef498567957
+ PingOneProtect: 4ced7b27c9e26d1ea9b8d29775121efd738d9000
+ PingOneSignals: 3d14d429201435960e062e3481356b9f21fa657f
PingOrchestrate: 90365995dc6fc5bd14f78ae5c04f07d46d265523
PingPush: aef2216cade878592b6c7166a90d8b15f1c0b649
PingStorage: fdaebdd80f4359dc7eb4f780429cc6840ed01cee
@@ -3691,6 +3734,7 @@ SPEC CHECKSUMS:
RNPingLogger: b7dd73f42b3fea86a56fc7a35999cf4333fbde20
RNPingOath: 261b44b957975c0c036b1bed6ef953c4767b8fcc
RNPingOidc: d490892cb09a11c189b2259bca0fecfc09a109fd
+ RNPingProtect: cbfd801e3ce9b326dd190772ff1ba16735d4fe78
RNPingPush: 3f02ee0f3d971c0d8affdb13b8fcefc4de789de1
RNPingStorage: 2c45c6d29cfcf8e1ed8ec3e32df1443e29dabd67
RNScreens: 846d53087db560ed5fbc34feb0643adb5f9602c5
diff --git a/PingSampleApp/ui/davinci/components/atoms/DaVinciErrorList.tsx b/PingSampleApp/ui/davinci/components/atoms/DaVinciErrorList.tsx
index 44f62d24f..fa5440a69 100644
--- a/PingSampleApp/ui/davinci/components/atoms/DaVinciErrorList.tsx
+++ b/PingSampleApp/ui/davinci/components/atoms/DaVinciErrorList.tsx
@@ -27,10 +27,20 @@ export type DaVinciErrorListProps = {
* @returns User-facing message.
*/
function describeError(error: DaVinciFieldValidationError): string {
- if (error.code === 'REQUIRED') {
- return 'This field is required.';
+ switch (error.code) {
+ case 'REQUIRED':
+ return 'This field is required.';
+ case 'REGEX_ERROR':
+ return error.message;
+ case 'INVALID_LENGTH':
+ return `Password must be between ${error.min} and ${error.max} characters.`;
+ case 'UNIQUE_CHARACTER':
+ return `Password must contain at least ${error.min} unique characters.`;
+ case 'MAX_REPEAT':
+ return `Password cannot repeat a character more than ${error.max} times.`;
+ case 'MIN_CHARACTERS':
+ return `Password must contain at least ${error.min} ${error.character} characters.`;
}
- return error.message;
}
/**
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciLabelField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciLabelField.tsx
index 696b8278c..e4d9dc018 100644
--- a/PingSampleApp/ui/davinci/components/molecules/DaVinciLabelField.tsx
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciLabelField.tsx
@@ -6,9 +6,10 @@
*/
import React from 'react';
-import { Text, View } from 'react-native';
+import { View } from 'react-native';
import type { LabelCollector } from '@ping-identity/rn-davinci';
import { davinciFieldStyles } from '../../../../src/styles/davinciStyles';
+import RichTextLabel from './RichTextLabel';
import type { DaVinciCollectorRendererProps } from './types';
/**
@@ -24,9 +25,11 @@ export default function DaVinciLabelField(
const labelCollector = collector as LabelCollector;
return (
-
- {labelCollector.content}
-
+
);
}
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordField.tsx
index 619aa9e9a..facf607ad 100644
--- a/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordField.tsx
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordField.tsx
@@ -5,9 +5,12 @@
* of the MIT license. See the LICENSE file for details.
*/
-import React from 'react';
+import React, { useState } from 'react';
import { View } from 'react-native';
-import type { PasswordCollector } from '@ping-identity/rn-davinci';
+import type {
+ DaVinciFieldValidationError,
+ PasswordCollector,
+} from '@ping-identity/rn-davinci';
import PingTextInput from '../../../components/atoms/PingTextInput';
import DaVinciErrorList from '../atoms/DaVinciErrorList';
import DaVinciPasswordRequirements from './DaVinciPasswordRequirements';
@@ -23,7 +26,10 @@ import type { DaVinciCollectorRendererProps } from './types';
export default function DaVinciPasswordField(
props: DaVinciCollectorRendererProps,
): React.ReactElement {
- const { collector, value, onChange } = props;
+ const { collector, value, onChange, onValidate } = props;
+ const [manualErrors, setManualErrors] = useState<
+ DaVinciFieldValidationError[] | null
+ >(null);
const passwordCollector = collector as PasswordCollector;
const stringValue = typeof value === 'string' ? value : '';
@@ -36,7 +42,17 @@ export default function DaVinciPasswordField(
: passwordCollector.label
}
value={stringValue}
- onChangeText={onChange}
+ onChangeText={value => {
+ setManualErrors(null);
+ onChange(value);
+ }}
+ onBlur={() => {
+ onValidate(collector.key, stringValue)
+ .then(setManualErrors)
+ .catch(error => {
+ console.warn('[DaVinci] Password validation failed:', error);
+ });
+ }}
secureTextEntry
allowPasswordToggle
autoCapitalize="none"
@@ -48,7 +64,7 @@ export default function DaVinciPasswordField(
value={stringValue}
/>
) : null}
-
+
);
}
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciPhoneNumberField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciPhoneNumberField.tsx
index 6ca59ccc5..c183b7310 100644
--- a/PingSampleApp/ui/davinci/components/molecules/DaVinciPhoneNumberField.tsx
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciPhoneNumberField.tsx
@@ -7,8 +7,12 @@
import React, { useState } from 'react';
import { View } from 'react-native';
-import type { PhoneNumberCollector } from '@ping-identity/rn-davinci';
+import type {
+ DaVinciFieldValidationError,
+ PhoneNumberCollector,
+} from '@ping-identity/rn-davinci';
import PingTextInput from '../../../components/atoms/PingTextInput';
+import DaVinciErrorList from '../atoms/DaVinciErrorList';
import DaVinciFieldLabel from '../atoms/DaVinciFieldLabel';
import PickerModal from '../atoms/PickerModal';
import { davinciFieldStyles } from '../../../../src/styles/davinciStyles';
@@ -67,7 +71,7 @@ function countryCodeForDialCode(dialCode: string): string | undefined {
function resolveValue(
value: unknown,
collector: PhoneNumberCollector,
-): { countryCode: string; phoneNumber: string } {
+): { countryCode: string; phoneNumber: string; extension: string } {
let rawCode =
value &&
typeof value === 'object' &&
@@ -89,7 +93,15 @@ function resolveValue(
? (value as { phoneNumber: string }).phoneNumber
: (collector.phoneNumber ?? '');
- return { countryCode: rawCode, phoneNumber };
+ const extension =
+ value &&
+ typeof value === 'object' &&
+ 'extension' in value &&
+ typeof (value as { extension: unknown }).extension === 'string'
+ ? (value as { extension: string }).extension
+ : (collector.extension ?? '');
+
+ return { countryCode: rawCode, phoneNumber, extension };
}
/**
@@ -123,11 +135,14 @@ function triggerLabel(isoCode: string): string {
export default function DaVinciPhoneNumberField(
props: DaVinciCollectorRendererProps,
): React.ReactElement {
- const { collector, value, onChange } = props;
+ const { collector, value, onChange, onValidate } = props;
const phoneCollector = collector as PhoneNumberCollector;
const current = resolveValue(value, phoneCollector);
const [modalVisible, setModalVisible] = useState(false);
+ const [manualErrors, setManualErrors] = useState<
+ DaVinciFieldValidationError[] | null
+ >(null);
const pickerOptions = COUNTRIES.map(c => ({
value: c.countryCode,
@@ -150,29 +165,59 @@ export default function DaVinciPhoneNumberField(
visible={modalVisible}
onOpen={() => setModalVisible(true)}
onClose={() => setModalVisible(false)}
- onSelect={isoCode =>
+ onSelect={isoCode => {
+ setManualErrors(null);
onChange({
countryCode: isoCode,
phoneNumber: current.phoneNumber,
- })
- }
+ extension: current.extension,
+ });
+ }}
/>
+ onChangeText={text => {
+ setManualErrors(null);
onChange({
countryCode: current.countryCode,
phoneNumber: text,
- })
- }
+ extension: current.extension,
+ });
+ }}
+ onBlur={() => {
+ onValidate(collector.key, current.phoneNumber)
+ .then(setManualErrors)
+ .catch(error => {
+ console.warn(
+ '[DaVinci] Phone number validation failed:',
+ error,
+ );
+ });
+ }}
keyboardType="phone-pad"
containerStyle={{ marginBottom: 0 }}
/>
+ {phoneCollector.showExtension ? (
+ {
+ setManualErrors(null);
+ onChange({
+ countryCode: current.countryCode,
+ phoneNumber: current.phoneNumber,
+ extension,
+ });
+ }}
+ keyboardType="phone-pad"
+ />
+ ) : null}
+
);
}
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciTextField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciTextField.tsx
index b719ad2d0..ff7f5f4e4 100644
--- a/PingSampleApp/ui/davinci/components/molecules/DaVinciTextField.tsx
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciTextField.tsx
@@ -5,9 +5,12 @@
* of the MIT license. See the LICENSE file for details.
*/
-import React from 'react';
+import React, { useState } from 'react';
import { View } from 'react-native';
-import type { TextCollector } from '@ping-identity/rn-davinci';
+import type {
+ DaVinciFieldValidationError,
+ TextCollector,
+} from '@ping-identity/rn-davinci';
import PingTextInput from '../../../components/atoms/PingTextInput';
import DaVinciErrorList from '../atoms/DaVinciErrorList';
import { davinciFieldStyles } from '../../../../src/styles/davinciStyles';
@@ -22,7 +25,10 @@ import type { DaVinciCollectorRendererProps } from './types';
export default function DaVinciTextField(
props: DaVinciCollectorRendererProps,
): React.ReactElement {
- const { collector, value, onChange } = props;
+ const { collector, value, onChange, onValidate } = props;
+ const [manualErrors, setManualErrors] = useState<
+ DaVinciFieldValidationError[] | null
+ >(null);
const textCollector = collector as TextCollector;
const stringValue = typeof value === 'string' ? value : textCollector.value;
const isMultiline = stringValue.length > 60;
@@ -36,12 +42,22 @@ export default function DaVinciTextField(
: textCollector.label
}
value={stringValue}
- onChangeText={onChange}
+ onChangeText={value => {
+ setManualErrors(null);
+ onChange(value);
+ }}
+ onBlur={() => {
+ onValidate(collector.key, stringValue)
+ .then(setManualErrors)
+ .catch(error => {
+ console.warn('[DaVinci] Text validation failed:', error);
+ });
+ }}
autoCapitalize="none"
autoCorrect={false}
multiline={isMultiline}
/>
-
+
);
}
diff --git a/PingSampleApp/ui/davinci/components/molecules/types.ts b/PingSampleApp/ui/davinci/components/molecules/types.ts
index 087b16248..2e0210f08 100644
--- a/PingSampleApp/ui/davinci/components/molecules/types.ts
+++ b/PingSampleApp/ui/davinci/components/molecules/types.ts
@@ -6,6 +6,7 @@
*/
import type {
+ DaVinciFieldValidationError,
DaVinciFormValue,
DaVinciNormalizedCollector,
IdpCollector,
@@ -35,6 +36,17 @@ export type DaVinciCollectorRendererProps = {
* Submits the current form. Used by submit buttons and flow links.
*/
onSubmit: () => void;
+ /**
+ * Validates the current value for a collector.
+ *
+ * @param collectorKey Collector key to validate.
+ * @param value Current rendered value for the collector.
+ * @returns Validation errors for the collector.
+ */
+ onValidate: (
+ collectorKey: string,
+ value: DaVinciFormValue,
+ ) => Promise;
/**
* Immediately advances the flow using a specific {@link FlowCollector} key.
*
diff --git a/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx b/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
index a54cee18f..291486db2 100644
--- a/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
+++ b/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
@@ -59,6 +59,7 @@ export default function DaVinciClientPanel(
hasActiveSession,
isSessionCheckRunning,
onSubmit,
+ onValidate,
onFlowAction,
onIdpAuthorize,
onPollStatus,
@@ -75,6 +76,7 @@ export default function DaVinciClientPanel(
form={form}
loading={loading}
onSubmit={onSubmit}
+ onValidate={onValidate}
onFlowAction={onFlowAction}
onIdpAuthorize={onIdpAuthorize}
onPollStatus={onPollStatus}
diff --git a/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx b/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
index f18b64c06..bfe7aaae5 100644
--- a/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
+++ b/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
@@ -16,6 +16,8 @@ import type {
PollingCollector,
PollingStatus,
UnsupportedDaVinciField,
+ DaVinciFormValue,
+ DaVinciFieldValidationError,
} from '@ping-identity/rn-davinci';
import { commonStyles } from '../../../../src/styles/common';
import { colors } from '../../../../src/styles/colors';
@@ -43,6 +45,17 @@ export type DaVinciContinueNodePanelProps = {
* Submits the current form by calling `next` with the planned payload.
*/
onSubmit: () => void;
+ /**
+ * Validates the current value for a collector.
+ *
+ * @param collectorKey Collector key to validate.
+ * @param value Current rendered value for the collector.
+ * @returns Validation errors for the collector.
+ */
+ onValidate: (
+ collectorKey: string,
+ value: DaVinciFormValue,
+ ) => Promise;
/**
* Submits a flow collector (`SUBMIT_BUTTON`, `ACTION`, `FLOW_BUTTON`,
* `FLOW_LINK`) by key.
@@ -165,6 +178,7 @@ export default function DaVinciContinueNodePanel(
form,
loading,
onSubmit,
+ onValidate,
onFlowAction,
onIdpAuthorize,
onPollStatus,
@@ -195,6 +209,7 @@ export default function DaVinciContinueNodePanel(
collector={collector}
value={values[collector.key]}
onChange={next => setValue(collector.key, next)}
+ onValidate={onValidate}
onSubmit={onSubmit}
onFlowAction={onFlowAction}
onIdpAuthorize={onIdpAuthorize}
diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
index 75dc63d9c..1f37dc387 100644
--- a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
+++ b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
@@ -11,7 +11,9 @@ import {
useDaVinciContext,
useDaVinciForm,
type DaVinciError,
+ type DaVinciFieldValidationError,
type DaVinciFormResult,
+ type DaVinciFormValue,
type DaVinciNode,
type IdpCollector,
type PollingCollector,
@@ -49,6 +51,17 @@ export type UseDaVinciClientPanelControllerResult = {
* Submits the current form by calling `next` with the planned payload.
*/
onSubmit: () => void;
+ /**
+ * Validates the current value for a collector.
+ *
+ * @param collectorKey Collector key to validate.
+ * @param value Current rendered value for the collector.
+ * @returns Validation errors for the collector.
+ */
+ onValidate: (
+ collectorKey: string,
+ value: DaVinciFormValue,
+ ) => Promise;
/**
* Submits a flow collector (`SUBMIT_BUTTON`, `ACTION`, `FLOW_BUTTON`,
* `FLOW_LINK`) by key.
@@ -113,8 +126,17 @@ export function useDaVinciClientPanelController(
): UseDaVinciClientPanelControllerResult {
const { onAuthenticated } = options;
const davinciContext = useDaVinciContext();
- const { node, loading, error, start, next, user, logoutUser, pollStatus } =
- useDaVinci();
+ const {
+ node,
+ loading,
+ error,
+ start,
+ next,
+ validate,
+ user,
+ logoutUser,
+ pollStatus,
+ } = useDaVinci();
const externalIdpLogger = useMemo(() => logger({ level: 'debug' }), []);
const externalIdp = useMemo(
() =>
@@ -209,6 +231,14 @@ export function useDaVinciClientPanelController(
});
}, [form, loading, next, onProtectCollect]);
+ const onValidate = useCallback(
+ (
+ collectorKey: string,
+ value: DaVinciFormValue,
+ ): Promise => validate(collectorKey, value),
+ [validate],
+ );
+
const onIdpAuthorize = useCallback(
async (collector: IdpCollector): Promise => {
if (loading) {
@@ -305,6 +335,7 @@ export function useDaVinciClientPanelController(
hasActiveSession,
isSessionCheckRunning,
onSubmit,
+ onValidate,
onFlowAction,
onIdpAuthorize,
onPollStatus,
diff --git a/PingTestRunner/__tests__/integration/davinci.test.ts b/PingTestRunner/__tests__/integration/davinci.test.ts
index 9379866a5..a96dd2b1e 100644
--- a/PingTestRunner/__tests__/integration/davinci.test.ts
+++ b/PingTestRunner/__tests__/integration/davinci.test.ts
@@ -25,6 +25,7 @@ type NativeDaVinciMock = {
configureDaVinci: jest.Mock;
start: jest.Mock;
next: jest.Mock;
+ validate: jest.Mock;
getSession: jest.Mock;
refresh: jest.Mock;
revoke: jest.Mock;
@@ -67,6 +68,7 @@ function makeMock(
type: 'SuccessNode',
session: { value: 'session-token' },
})),
+ validate: jest.fn(async () => []),
getSession: jest.fn(async () => ({ accessToken: 'mock-access-token' })),
refresh: jest.fn(async () => ({ accessToken: 'mock-refreshed-token' })),
revoke: jest.fn(async () => true),
@@ -203,6 +205,7 @@ describe('@ping-identity/rn-davinci — integration', () => {
const methods = [
'start',
'next',
+ 'validate',
'user',
'refresh',
'revoke',
@@ -262,6 +265,34 @@ describe('@ping-identity/rn-davinci — integration', () => {
expect(mock.next).toHaveBeenCalledWith('davinci-id-mock', input);
});
+ it('validate() forwards a single-entry collector payload to native', async () => {
+ const mock = makeMock();
+ const mod = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+ const errors = await client.validate('username', 'alice');
+ expect(errors).toEqual([]);
+ expect(mock.validate).toHaveBeenCalledTimes(1);
+ expect(mock.validate).toHaveBeenCalledWith(
+ 'davinci-id-mock',
+ 'username',
+ {
+ collectors: [{ key: 'username', value: 'alice' }],
+ },
+ );
+ });
+
+ it('validate() resolves with the validation errors returned by native', async () => {
+ const mock = makeMock({
+ validate: jest.fn(async () => [{ code: 'REQUIRED' }]),
+ });
+ const mod = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+ const errors = await client.validate('username', '');
+ expect(errors).toEqual([{ code: 'REQUIRED' }]);
+ });
+
it('user() resolves with the active session', async () => {
const mock = makeMock();
const mod = await loadDaVinci(mock);
@@ -376,6 +407,22 @@ describe('@ping-identity/rn-davinci — integration', () => {
name: 'DaVinciError',
});
});
+
+ it('validate() propagates native errors as DaVinciError', async () => {
+ const mock = makeMock({
+ validate: jest.fn(async () => {
+ throw new Error('validate failed');
+ }),
+ });
+ const mod = await loadDaVinci(mock);
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ await client.start();
+ await expect(
+ client.validate('does-not-exist', 'value'),
+ ).rejects.toMatchObject({
+ name: 'DaVinciError',
+ });
+ });
});
describe('normalizeCollectors()', () => {
@@ -477,7 +524,85 @@ describe('@ping-identity/rn-davinci — integration', () => {
]);
});
- it('PASSWORD — round-trips a string value and surfaces passwordPolicy', async () => {
+ it('SINGLE_CHECKBOX — preserves metadata and round-trips a boolean', async () => {
+ const richContent = {
+ content: 'I agree to the {terms}.',
+ replacements: {
+ terms: {
+ value: 'terms',
+ href: 'https://example.com/terms',
+ type: 'link',
+ target: '_blank',
+ },
+ },
+ };
+ const mod = await loadDaVinci(
+ makeMock({
+ start: jest.fn(async () => ({
+ type: 'ContinueNode',
+ collectors: [
+ {
+ key: 'terms',
+ type: 'SINGLE_CHECKBOX',
+ label: 'Agree to terms',
+ required: true,
+ value: false,
+ appearance: 'CHECKBOX',
+ errorMessage: 'Agreement is required.',
+ richContent,
+ errors: [{ code: 'REQUIRED' }],
+ },
+ ],
+ })),
+ }),
+ );
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ const node = await client.start();
+ const collectors = mod.normalizeCollectors(node.collectors);
+ expect(collectors[0]).toMatchObject({
+ kind: 'boolean',
+ appearance: 'CHECKBOX',
+ richContent,
+ errors: [{ code: 'REQUIRED' }],
+ errorMessage: 'Agreement is required.',
+ });
+ const plan = mod.buildNextInput(node, { terms: true });
+ expect(plan.input.collectors).toEqual([{ key: 'terms', value: true }]);
+ });
+
+ it('READ_ONLY_TEXT — preserves content and excludes it from payload', async () => {
+ const mod = await loadDaVinci(
+ makeMock({
+ start: jest.fn(async () => ({
+ type: 'ContinueNode',
+ collectors: [
+ {
+ key: 'agreement',
+ type: 'READ_ONLY_TEXT',
+ content: 'Review this agreement.',
+ title: 'Agreement',
+ titleEnabled: true,
+ enabled: true,
+ agreementId: 'agreement-id',
+ useDynamicAgreement: false,
+ },
+ ],
+ })),
+ }),
+ );
+ const client = mod.createDaVinciClient(VALID_CONFIG);
+ const node = await client.start();
+ const collectors = mod.normalizeCollectors(node.collectors);
+ expect(collectors[0]).toMatchObject({
+ type: 'READ_ONLY_TEXT',
+ kind: 'output',
+ content: 'Review this agreement.',
+ title: 'Agreement',
+ });
+ expect(mod.buildNextInput(node, {}).input.collectors).toEqual([]);
+ });
+
+ it('PASSWORD — preserves validation and round-trips a string value', async () => {
const policy = {
name: 'Default',
description: 'Default policy',
@@ -507,6 +632,7 @@ describe('@ping-identity/rn-davinci — integration', () => {
required: true,
value: '',
clearPassword: true,
+ validation: { regex: '^.{8,}$' },
passwordPolicy: policy,
},
],
@@ -519,6 +645,7 @@ describe('@ping-identity/rn-davinci — integration', () => {
expect(collectors[0]).toMatchObject({
type: 'PASSWORD',
clearPassword: true,
+ validation: { regex: '^.{8,}$' },
passwordPolicy: policy,
});
const plan = mod.buildNextInput(node, { password: 's3cret' });
@@ -595,13 +722,29 @@ describe('@ping-identity/rn-davinci — integration', () => {
]);
});
- it('LABEL — output_only and excluded from the payload', async () => {
+ it('LABEL — preserves richContent and excludes it from the payload', async () => {
+ const richContent = {
+ content: 'Welcome {name}!',
+ replacements: {
+ name: {
+ value: 'back',
+ href: '',
+ type: 'text',
+ target: '',
+ },
+ },
+ };
const mod = await loadDaVinci(
makeMock({
start: jest.fn(async () => ({
type: 'ContinueNode',
collectors: [
- { key: 'banner', type: 'LABEL', content: 'Welcome back!' },
+ {
+ key: 'banner',
+ type: 'LABEL',
+ content: 'Welcome back!',
+ richContent,
+ },
],
})),
}),
@@ -612,6 +755,7 @@ describe('@ping-identity/rn-davinci — integration', () => {
expect(collectors[0]).toMatchObject({
type: 'LABEL',
content: 'Welcome back!',
+ richContent,
executionMode: 'output_only',
});
const plan = mod.buildNextInput(node, {});
@@ -684,7 +828,7 @@ describe('@ping-identity/rn-davinci — integration', () => {
]);
});
- it('PHONE_NUMBER — round-trips { countryCode, phoneNumber }', async () => {
+ it('PHONE_NUMBER — round-trips { countryCode, phoneNumber, extension }', async () => {
const mod = await loadDaVinci(
makeMock({
start: jest.fn(async () => ({
@@ -699,6 +843,9 @@ describe('@ping-identity/rn-davinci — integration', () => {
validatePhoneNumber: true,
countryCode: 'US',
phoneNumber: '',
+ extension: '',
+ showExtension: true,
+ extensionLabel: 'Extension',
},
],
})),
@@ -710,14 +857,24 @@ describe('@ping-identity/rn-davinci — integration', () => {
expect(collectors[0]).toMatchObject({
defaultCountryCode: 'US',
validatePhoneNumber: true,
+ showExtension: true,
+ extensionLabel: 'Extension',
});
const plan = mod.buildNextInput(node, {
- phone: { countryCode: 'GB', phoneNumber: '+447700900123' },
+ phone: {
+ countryCode: 'GB',
+ phoneNumber: '+447700900123',
+ extension: '123',
+ },
});
expect(plan.input.collectors).toEqual([
{
key: 'phone',
- value: { countryCode: 'GB', phoneNumber: '+447700900123' },
+ value: {
+ countryCode: 'GB',
+ phoneNumber: '+447700900123',
+ extension: '123',
+ },
},
]);
});
diff --git a/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts b/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts
index dbf6f0ddf..973919825 100644
--- a/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts
+++ b/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts
@@ -62,22 +62,23 @@ type _BrowserMockedMethods = Pick;
type _DeviceClientMockedMethods = Pick<
DeviceClientSpec,
'create' | 'get' | 'update' | 'deleteDevice' | 'dispose'
- >;
+>;
// ─── rn-davinci ─────────────────────────────────────────────────────────────
-// jest.setup.js mocks: configureDaVinci, start, next, getSession, refresh,
-// revoke, userinfo, logout, dispose
+// jest.setup.js mocks: configureDaVinci, start, next, validate, getSession,
+// refresh, revoke, userinfo, logout, dispose
type _DaVinciMockedMethods = Pick<
DaVinciSpec,
| 'configureDaVinci'
| 'start'
| 'next'
+ | 'validate'
| 'getSession'
| 'refresh'
| 'revoke'
| 'userinfo'
| 'logout'
| 'dispose'
- >;
+>;
// ─── rn-device-id ───────────────────────────────────────────────────────────
// jest.setup.js mocks: getDefaultDeviceId
diff --git a/PingTestRunner/jest.setup.js b/PingTestRunner/jest.setup.js
index 28d7573eb..863465ff9 100644
--- a/PingTestRunner/jest.setup.js
+++ b/PingTestRunner/jest.setup.js
@@ -112,6 +112,13 @@ jest.mock('../packages/davinci/src/NativeRNPingDavinci', () => {
assertActive('next', id);
return { type: 'SuccessNode', session: { value: 'session-mock' } };
}),
+ // Joins the global mock rather than following pollDaVinci's per-test
+ // precedent: validate is exercised broadly (scenario wiring, other
+ // integration suites), matching the majority of methods here.
+ validate: jest.fn(async (id) => {
+ assertActive('validate', id);
+ return [];
+ }),
getSession: jest.fn(async (id) => {
assertActive('getSession', id);
return { accessToken: 'mock-access-token' };
diff --git a/PingTestRunner/scenarios/DaVinciScenario.tsx b/PingTestRunner/scenarios/DaVinciScenario.tsx
index d1493bfbf..8abb940d5 100644
--- a/PingTestRunner/scenarios/DaVinciScenario.tsx
+++ b/PingTestRunner/scenarios/DaVinciScenario.tsx
@@ -361,6 +361,27 @@ function CollectorField({
);
}
+ if (collector.type === 'SINGLE_CHECKBOX') {
+ return (
+
+ );
+ }
+
+ if (collector.type === 'READ_ONLY_TEXT') {
+ return (
+
+ {collector.titleEnabled && collector.title
+ ? `${collector.title}\n`
+ : ''}
+ {collector.content}
+
+ );
+ }
+
if (collector.type === 'SUBMIT_BUTTON') {
return (