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 (