Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/long-singers-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/davinci-client': minor
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we mark this as major if it is a breaking change?

---

A new PhoneNumberExtensionCollector has been added to support phone number fields that include an extension. When a DaVinci PHONE_NUMBER field has showExtension: true, the SDK now produces a PhoneNumberExtensionCollector instead of a PhoneNumberCollector.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,6 @@ GEMINI.md
.claude/worktrees
.claude/settings.local.json
.opensource

# Polaris
.polaris-setup-progress.json
97 changes: 90 additions & 7 deletions e2e/davinci-app/components/object-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import type {
DeviceAuthenticationCollector,
DeviceRegistrationCollector,
PhoneNumberCollector,
PhoneNumberExtensionCollector,
PhoneNumberExtensionInputValue,
PhoneNumberInputValue,
Updater,
} from '@forgerock/davinci-client/types';

Expand All @@ -19,11 +22,16 @@ import type {
*/
export default function objectValueComponent(
formEl: HTMLFormElement,
collector: DeviceRegistrationCollector | DeviceAuthenticationCollector | PhoneNumberCollector,
collector:
| DeviceRegistrationCollector
| DeviceAuthenticationCollector
| PhoneNumberCollector
| PhoneNumberExtensionCollector,
updater:
| Updater<DeviceRegistrationCollector>
| Updater<DeviceAuthenticationCollector>
| Updater<PhoneNumberCollector>,
| Updater<PhoneNumberCollector>
| Updater<PhoneNumberExtensionCollector>,
submitForm: () => void,
) {
if (
Expand Down Expand Up @@ -61,7 +69,7 @@ export default function objectValueComponent(
buttonEl.textContent = option.label;
formEl.appendChild(buttonEl);
}
} else {
} else if (collector.type === 'PhoneNumberCollector') {
const phoneLabel = document.createElement('label');
phoneLabel.textContent = collector.output.label || 'Phone Number';
phoneLabel.className = 'object-options-title';
Expand All @@ -73,6 +81,9 @@ export default function objectValueComponent(
phoneInput.setAttribute('name', 'phone-number-input');
phoneInput.setAttribute('placeholder', 'Enter phone number');

formEl.appendChild(phoneLabel);
formEl.appendChild(phoneInput);

// Add change event listener
phoneInput.addEventListener('change', (event) => {
// Properly type the event target
Expand All @@ -84,13 +95,85 @@ export default function objectValueComponent(
return;
}

updater({
const phoneNumberInputValue: PhoneNumberInputValue = {
phoneNumber: selectedValue,
countryCode: collector.output.value?.countryCode || '',
} as any);
};
const phoneNumberUpdater = updater as Updater<PhoneNumberCollector>;
phoneNumberUpdater(phoneNumberInputValue);
});
} else if (collector.type === 'PhoneNumberExtensionCollector') {
const phoneLabel = document.createElement('label');
phoneLabel.textContent = collector.output.label || 'Phone Number';
phoneLabel.className = 'object-options-title';
phoneLabel.setAttribute('for', 'phone-number-input-1');

formEl.appendChild(phoneLabel);
formEl.appendChild(phoneInput);
const phoneInput = document.createElement('input');
phoneInput.setAttribute('type', 'tel');
phoneInput.setAttribute('id', 'phone-number-input-1');
phoneInput.setAttribute('name', 'phone-number-input-1');
phoneInput.setAttribute('placeholder', 'Enter phone number');

const extensionLabel = document.createElement('label');
extensionLabel.textContent = collector.output.extensionLabel || 'Extension';
extensionLabel.className = 'object-options-title';
extensionLabel.setAttribute('for', 'extension-input-1');

const extensionInput = document.createElement('input');
extensionInput.setAttribute('type', 'text');
extensionInput.setAttribute('id', 'extension-input-1');
extensionInput.setAttribute('name', 'extension-input-1');
extensionInput.setAttribute('placeholder', 'Enter extension');

const divEl = document.createElement('div');
divEl.style = 'display: flex; gap: 8px;';
divEl.appendChild(phoneLabel);
divEl.appendChild(phoneInput);
divEl.appendChild(extensionLabel);
divEl.appendChild(extensionInput);

formEl.appendChild(divEl);

const phoneNumberExtensionUpdater = updater as Updater<PhoneNumberExtensionCollector>;

// Add change event listener for phone number input
phoneInput.addEventListener('change', (event) => {
const target = event.target as HTMLInputElement;
const phoneValue = target.value;
const extensionValue = extensionInput.value;

if (!phoneValue) {
console.error('No value found for phone number');
return;
}

const phoneNumberExtensionInputValue: PhoneNumberExtensionInputValue = {
phoneNumber: phoneValue,
countryCode: collector.output.value?.countryCode || '',
extension: extensionValue,
};

phoneNumberExtensionUpdater(phoneNumberExtensionInputValue);
});

// Add change event listener for extension input
extensionInput.addEventListener('change', (event) => {
const target = event.target as HTMLInputElement;
const extensionValue = target.value;
const phoneValue = phoneInput.value;

if (!extensionValue) {
console.error('No value found for extension');
return;
}

const phoneNumberExtensionInputValue: PhoneNumberExtensionInputValue = {
phoneNumber: phoneValue,
countryCode: collector.output.value?.countryCode || '',
extension: extensionValue,
};

phoneNumberExtensionUpdater(phoneNumberExtensionInputValue);
});
Comment on lines +160 to +177
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Allow clearing extension values instead of blocking empty updates.

At Line 165, returning early on empty extension prevents users from clearing a previously entered extension, which can leave stale data in collector state.

🔧 Proposed fix
     extensionInput.addEventListener('change', (event) => {
@@
-      if (!extensionValue) {
-        console.error('No value found for extension');
-        return;
-      }
-
       const phoneNumberExtensionInputValue: PhoneNumberExtensionInputValue = {
         phoneNumber: phoneValue,
         countryCode: collector.output.value?.countryCode || '',
         extension: extensionValue,
       };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
extensionInput.addEventListener('change', (event) => {
const target = event.target as HTMLInputElement;
const extensionValue = target.value;
const phoneValue = phoneInput.value;
if (!extensionValue) {
console.error('No value found for extension');
return;
}
const phoneNumberExtensionInputValue: PhoneNumberExtensionInputValue = {
phoneNumber: phoneValue,
countryCode: collector.output.value?.countryCode || '',
extension: extensionValue,
};
phoneNumberExtensionUpdater(phoneNumberExtensionInputValue);
});
extensionInput.addEventListener('change', (event) => {
const target = event.target as HTMLInputElement;
const extensionValue = target.value;
const phoneValue = phoneInput.value;
const phoneNumberExtensionInputValue: PhoneNumberExtensionInputValue = {
phoneNumber: phoneValue,
countryCode: collector.output.value?.countryCode || '',
extension: extensionValue,
};
phoneNumberExtensionUpdater(phoneNumberExtensionInputValue);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/davinci-app/components/object-value.ts` around lines 160 - 177, The
change blocks clearing extension values by returning early when extensionValue
is empty; update the handler for extensionInput.change (the event listener using
extensionInput and phoneInput) to allow empty values so clearing updates state:
remove the early return and always construct a PhoneNumberExtensionInputValue
(use extension: extensionValue || '' and countryCode:
collector.output.value?.countryCode || '') and call
phoneNumberExtensionUpdater(phoneNumberExtensionInputValue) so an empty
extension clears the stored value.

}
}
5 changes: 4 additions & 1 deletion e2e/davinci-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,10 @@ const urlParams = new URLSearchParams(window.location.search);
formEl, // You can ignore this; it's just for rendering
collector, // This is the plain object of the collector
);
} else if (collector.type === 'PhoneNumberCollector') {
} else if (
collector.type === 'PhoneNumberCollector' ||
collector.type === 'PhoneNumberExtensionCollector'
) {
objectValueComponent(
formEl, // You can ignore this; it's just for rendering
collector, // This is the plain object of the collector
Expand Down
9 changes: 6 additions & 3 deletions e2e/davinci-suites/src/form-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ test('Should render form fields', async ({ page }) => {
await page.locator('#combobox-field-key-3').check();
await page.locator('#combobox-field-key-2').uncheck();

await page.locator('#phone-number-input').fill('1234567890');
await page.locator('#phone-number-input-1').fill('1234567890');
await page.locator('#extension-input-1').fill('7890');

await expect(page.getByRole('button', { name: 'Flow Button' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Flow Link' })).toBeVisible();
Expand All @@ -42,9 +43,10 @@ test('Should render form fields', async ({ page }) => {

await page.getByRole('button', { name: 'Submit' }).click();
const request = await requestPromise;

const parsedData = JSON.parse(request.postData());
const postData = request.postData();
const parsedData = postData ? JSON.parse(postData) : {};
const data = parsedData.parameters.data;

Comment on lines +46 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard parameters.data access when POST body is absent.

At Line 47, parsedData can be {}, but Line 48 still assumes parsedData.parameters.data exists. This can fail with a TypeError and hide the real failure cause.

🔧 Proposed fix
 const postData = request.postData();
-const parsedData = postData ? JSON.parse(postData) : {};
-const data = parsedData.parameters.data;
+expect(postData).toBeTruthy();
+const parsedData = JSON.parse(postData!);
+const data = parsedData?.parameters?.data;
+expect(data).toBeTruthy();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const postData = request.postData();
const parsedData = postData ? JSON.parse(postData) : {};
const data = parsedData.parameters.data;
const postData = request.postData();
expect(postData).toBeTruthy();
const parsedData = JSON.parse(postData!);
const data = parsedData?.parameters?.data;
expect(data).toBeTruthy();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/davinci-suites/src/form-fields.test.ts` around lines 46 - 49, The test
accesses parsedData.parameters.data without guarding for missing POST body;
update the extraction of data (variables postData/parsedData/data) to safely
handle absent or malformed bodies by checking postData and parsedData.parameters
exist before accessing .data (e.g., use optional chaining or provide default
objects), and ensure data is assigned a safe default when missing so no
TypeError is thrown during the test.

expect(data.actionKey).toBe('submit');
expect(data.formData).toStrictEqual({
'text-input-key': 'The input',
Expand All @@ -55,6 +57,7 @@ test('Should render form fields', async ({ page }) => {
'phone-field': {
phoneNumber: '1234567890',
countryCode: 'GB',
extension: '7890', // Tests PhoneNumberExtensionCollector
},
});
});
Expand Down
3 changes: 2 additions & 1 deletion e2e/davinci-suites/src/phone-number-field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ test.describe('Device registration tests', () => {
await page.getByRole('button', { name: 'DEVICE_REGISTRATION' }).click();
await expect(page.getByText('SDK Automation - Device Registration')).toBeVisible();
await page.getByRole('button', { name: 'Text Message' }).click();
await expect(page.getByText('SDK Automation - Enter Phone Number')).toBeVisible();
await expect(page.getByText('SDK Automation [JS] - Enter Phone Number')).toBeVisible();
await page.getByRole('textbox', { name: 'Enter Phone Number' }).fill('3035550100');
await expect(page.getByText('Extension')).not.toBeVisible(); // Tests standard PhoneNumberCollector
await page.getByRole('button', { name: 'Submit' }).click();

await expect(page.getByText('SMS/Voice MFA Registered')).toBeVisible();
Expand Down
2 changes: 2 additions & 0 deletions packages/davinci-client/src/lib/client.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
MultiValueCollectors,
FidoRegistrationInputValue,
FidoAuthenticationInputValue,
PhoneNumberExtensionInputValue,
} from './collector.types.js';
import type {
InitFlow,
Expand Down Expand Up @@ -338,6 +339,7 @@ export async function davinci<ActionType extends ActionTypes = ActionTypes>({
| string
| string[]
| PhoneNumberInputValue
| PhoneNumberExtensionInputValue
| FidoRegistrationInputValue
| FidoAuthenticationInputValue,
index?: number,
Expand Down
114 changes: 114 additions & 0 deletions packages/davinci-client/src/lib/collector.types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ import type {
ReadOnlyCollector,
QrCodeCollector,
AgreementCollector,
PhoneNumberCollector,
PhoneNumberExtensionCollector,
ObjectValueCollectorWithObjectValue,
InferValueObjectCollectorType,
PhoneNumberInputValue,
PhoneNumberOutputValue,
PhoneNumberExtensionInputValue,
PhoneNumberExtensionOutputValue,
} from './collector.types.js';

describe('Collector Types', () => {
Expand Down Expand Up @@ -358,6 +366,112 @@ describe('Collector Types', () => {

expectTypeOf(tCollector).toMatchTypeOf<FlowCollector>();
});

it('should correctly infer PhoneNumberCollector Type', () => {
const tCollector: InferValueObjectCollectorType<'PhoneNumberCollector'> = {
category: 'ObjectValueCollector',
error: null,
type: 'PhoneNumberCollector',
id: '',
name: '',
input: {
key: '',
value: { countryCode: '', phoneNumber: '' },
type: '',
validation: null,
},
output: {
key: '',
label: '',
type: '',
value: { countryCode: '', phoneNumber: '' },
},
};

expectTypeOf(tCollector).toEqualTypeOf<PhoneNumberCollector>();
});
});

describe('ObjectValueCollector Types', () => {
it('should correctly infer PhoneNumberExtensionCollector Type', () => {
const tCollector: InferValueObjectCollectorType<'PhoneNumberExtensionCollector'> = {
category: 'ObjectValueCollector',
error: null,
type: 'PhoneNumberExtensionCollector',
id: '',
name: '',
input: {
key: '',
value: { countryCode: '', phoneNumber: '', extension: '' },
type: '',
validation: null,
},
output: {
key: '',
label: '',
type: '',
extensionLabel: '',
value: {},
},
};

expectTypeOf(tCollector).toEqualTypeOf<PhoneNumberExtensionCollector>();
});

it('should validate PhoneNumberExtensionCollector structure', () => {
expectTypeOf<PhoneNumberExtensionCollector>()
.toHaveProperty('category')
.toEqualTypeOf<'ObjectValueCollector'>();
expectTypeOf<PhoneNumberExtensionCollector>()
.toHaveProperty('type')
.toEqualTypeOf<'PhoneNumberExtensionCollector'>();
expectTypeOf<
PhoneNumberExtensionCollector['input']['value']
>().toEqualTypeOf<PhoneNumberExtensionInputValue>();
expectTypeOf<
PhoneNumberExtensionCollector['output']['value']
>().toEqualTypeOf<PhoneNumberExtensionOutputValue>();
});

it('should validate PhoneNumberCollector structure', () => {
expectTypeOf<PhoneNumberCollector>().toEqualTypeOf<
ObjectValueCollectorWithObjectValue<
'PhoneNumberCollector',
PhoneNumberInputValue,
PhoneNumberOutputValue
>
>();
expectTypeOf<PhoneNumberCollector>()
.toHaveProperty('category')
.toEqualTypeOf<'ObjectValueCollector'>();
expectTypeOf<PhoneNumberCollector>()
.toHaveProperty('type')
.toEqualTypeOf<'PhoneNumberCollector'>();
expectTypeOf<PhoneNumberCollector['input']['value']>().toEqualTypeOf<PhoneNumberInputValue>();
});

it('should validate PhoneNumberCollector base type constraints', () => {
const collector: PhoneNumberCollector = {
category: 'ObjectValueCollector',
type: 'PhoneNumberCollector',
error: null,
id: 'test',
name: 'Test',
input: {
key: 'phone',
value: { countryCode: '+1', phoneNumber: '5555555555' },
type: 'string',
validation: null,
},
output: {
key: 'phone',
label: 'Phone Number',
type: 'phone',
value: { countryCode: '+1', phoneNumber: '5555555555' },
},
};
expectTypeOf(collector).toEqualTypeOf<PhoneNumberCollector>();
});
});

describe('InferNoValueCollectorType', () => {
Expand Down
Loading
Loading