Skip to content

Commit 65c5dcc

Browse files
authored
Merge pull request #638 from ForgeRock/SDKS-5000-form-fields-e2e
test(davinci-client): e2e tests for agreements, single checkbox, rich text
2 parents 1269793 + 3abf436 commit 65c5dcc

16 files changed

Lines changed: 379 additions & 132 deletions

.changeset/single-checkbox.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
'@forgerock/davinci-client': minor
33
---
44

5-
Adds support for the SINGLE_CHECKBOX field. A new ValidatedBooleanCollector type was introduced including validation support for required checkboxes and updater support for booleans.
5+
Adds support for the SINGLE_CHECKBOX field. A new ValidatedBooleanCollector interface was introduced including validation support for required checkboxes and updater support for booleans.
66

77
**Type improvements**
88

99
- `SingleValueCollectorWithValue<T, V>` and `ValidatedSingleValueCollectorWithValue<T, V>` are now generic over their value type (`V`, defaults to `string`), replacing the loose `string | number | boolean` union
1010

11+
- `ValidatedBooleanCollector` interface extends `ValidatedSingleValueCollectorWithValue`, intersecting `output` with `appearance: string` and `richContent?: CollectorRichContent`
12+
1113
- `Validator` is now generic over collector type `T`, replacing the hardcoded `string` input with `CollectorValueType<T>` — so validators receive the value type that matches their collector (e.g. `boolean` for `ValidatedBooleanCollector`, `string` for text collectors) rather than always `string`

e2e/davinci-app/components/boolean.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,48 +4,77 @@
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
7-
import type { ValidatedBooleanCollector, Updater } from '@forgerock/davinci-client/types';
7+
import type {
8+
ValidatedBooleanCollector,
9+
Updater,
10+
Validator,
11+
} from '@forgerock/davinci-client/types';
12+
import { dotToCamelCase, richContentInterpolation } from '../helper.js';
813

914
/**
1015
* Creates a single checkbox and attaches it to the form
11-
* @param {HTMLFormElement} formEl - The form element to attach the checkboxes to
16+
* @param {HTMLFormElement} formEl - The form element to attach the checkbox to
1217
* @param {ValidatedBooleanCollector} collector - Contains the configuration
1318
* @param {Updater} updater - Function to call when selection changes
19+
* @param {Validator} validator - Function to validate the input
1420
*/
1521
export default function booleanComponent(
1622
formEl: HTMLFormElement,
1723
collector: ValidatedBooleanCollector,
1824
updater: Updater<ValidatedBooleanCollector>,
25+
validator: Validator<ValidatedBooleanCollector>,
1926
) {
20-
// Create a container for the checkboxes
27+
const collectorKey = dotToCamelCase(collector.output.key);
28+
29+
// Create a container for the checkbox
2130
const containerDiv = document.createElement('div');
2231
containerDiv.className = 'single-checkbox-container';
2332

24-
// Create a heading/label for the checkbox group
25-
const groupLabel = document.createElement('div');
26-
groupLabel.textContent = collector.output.label || 'Single Checkbox';
27-
groupLabel.className = 'single-checkbox-label';
28-
containerDiv.appendChild(groupLabel);
29-
30-
// Create checkboxes for each option
33+
// Create a single checkbox
3134
const wrapper = document.createElement('div');
3235
wrapper.className = 'checkbox-wrapper';
3336

3437
const checkbox = document.createElement('input');
3538
checkbox.type = 'checkbox';
36-
checkbox.id = collector.output.key;
37-
checkbox.name = collector.output.key || 'single-checkbox-field';
39+
checkbox.id = collectorKey;
40+
checkbox.name = collectorKey;
3841
checkbox.checked = collector.output.value;
3942
checkbox.value = 'checked';
4043

4144
const label = document.createElement('label');
4245
label.htmlFor = checkbox.id;
43-
label.textContent = collector.output.label;
46+
47+
const { richContent } = collector.output;
48+
if (!richContent || richContent.replacements.length === 0) {
49+
label.textContent = collector.output.label;
50+
} else {
51+
const pRichText = richContentInterpolation(richContent);
52+
while (pRichText.firstChild) {
53+
label.appendChild(pRichText.firstChild);
54+
}
55+
}
4456

4557
// Add event listener to handle single-select behavior
4658
checkbox.addEventListener('change', (event) => {
47-
const target = event.target as HTMLInputElement;
48-
updater(target.checked);
59+
const checked = (event.target as HTMLInputElement).checked;
60+
const result = validator(checked);
61+
const errorEl = formEl?.querySelector(`.${collectorKey}-error`);
62+
63+
// Validate the input
64+
if (Array.isArray(result) && result.length && !errorEl) {
65+
const newErrorEl = document.createElement('div');
66+
newErrorEl.className = `${collectorKey}-error`;
67+
newErrorEl.innerText = result.join(', ');
68+
formEl?.querySelector(`#${collectorKey}`)?.after(newErrorEl);
69+
} else if (Array.isArray(result) && result.length) {
70+
return;
71+
} else {
72+
formEl.querySelector(`.${collectorKey}-error`)?.remove();
73+
const updateError = updater(checked);
74+
if (updateError && 'error' in updateError) {
75+
console.error(updateError.error.message);
76+
}
77+
}
4978
});
5079

5180
wrapper.appendChild(checkbox);
Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
77
import type { ReadOnlyCollector, RichTextCollector } from '@forgerock/davinci-client/types';
8+
import { richContentInterpolation } from '../helper.js';
89

910
export default function (
1011
formEl: HTMLFormElement,
@@ -28,32 +29,7 @@ export default function (
2829
}
2930

3031
// Interpolate the template by splitting on {{key}} and inserting links
31-
const segments = richContent.content.split(/\{\{(\w+)\}\}/);
32-
const replacementMap = new Map(richContent.replacements.map((r) => [r.key, r]));
32+
const pRichText = richContentInterpolation(richContent);
3333

34-
for (let i = 0; i < segments.length; i++) {
35-
if (i % 2 === 0) {
36-
// Text segment
37-
if (segments[i]) {
38-
p.appendChild(document.createTextNode(segments[i]));
39-
}
40-
} else {
41-
// Replacement key
42-
const replacement = replacementMap.get(segments[i]);
43-
if (replacement?.type === 'link') {
44-
const a = document.createElement('a');
45-
a.href = replacement.href;
46-
a.textContent = replacement.value;
47-
if (replacement.target) {
48-
a.target = replacement.target;
49-
if (replacement.target === '_blank') {
50-
a.rel = 'noopener noreferrer';
51-
}
52-
}
53-
p.appendChild(a);
54-
}
55-
}
56-
}
57-
58-
formEl?.appendChild(p);
34+
formEl?.appendChild(pRichText);
5935
}

e2e/davinci-app/components/text.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -16,7 +16,7 @@ export default function textComponent(
1616
formEl: HTMLFormElement,
1717
collector: TextCollector | ValidatedTextCollector,
1818
updater: Updater<TextCollector | ValidatedTextCollector>,
19-
validator: Validator,
19+
validator: Validator<ValidatedTextCollector>,
2020
) {
2121
const collectorKey = dotToCamelCase(collector.output.key);
2222
const label = document.createElement('label');

e2e/davinci-app/helper.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
7+
import type { CollectorRichContent } from '@forgerock/davinci-client';
8+
79
export function dotToCamelCase(str: string) {
810
return str
911
.split('.')
@@ -12,3 +14,38 @@ export function dotToCamelCase(str: string) {
1214
)
1315
.join('');
1416
}
17+
18+
// Interpolate the template by splitting on {{key}} and inserting links
19+
export function richContentInterpolation(richContent: CollectorRichContent): HTMLParagraphElement {
20+
const p = document.createElement('p');
21+
p.style.whiteSpace = 'pre-line';
22+
23+
const segments = richContent.content.split(/\{\{(\w+)\}\}/);
24+
const replacementMap = new Map(richContent.replacements.map((r) => [r.key, r]));
25+
26+
for (let i = 0; i < segments.length; i++) {
27+
if (i % 2 === 0) {
28+
// Text segment
29+
if (segments[i]) {
30+
p.appendChild(document.createTextNode(segments[i]));
31+
}
32+
} else {
33+
// Replacement key
34+
const replacement = replacementMap.get(segments[i]);
35+
if (replacement?.type === 'link') {
36+
const a = document.createElement('a');
37+
a.href = replacement.href;
38+
a.textContent = replacement.value;
39+
if (replacement.target) {
40+
a.target = replacement.target;
41+
if (replacement.target === '_blank') {
42+
a.rel = 'noopener noreferrer';
43+
}
44+
}
45+
p.appendChild(a);
46+
}
47+
}
48+
}
49+
50+
return p;
51+
}

e2e/davinci-app/main.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -300,7 +300,12 @@ const urlParams = new URLSearchParams(window.location.search);
300300
} else if (collector.type === 'MultiSelectCollector') {
301301
multiValueComponent(formEl, collector, davinciClient.update(collector));
302302
} else if (collector.type === 'ValidatedBooleanCollector') {
303-
booleanComponent(formEl, collector, davinciClient.update(collector));
303+
booleanComponent(
304+
formEl,
305+
collector,
306+
davinciClient.update(collector),
307+
davinciClient.validate(collector),
308+
);
304309
}
305310
});
306311

e2e/davinci-app/server-configs.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -46,13 +46,16 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
4646
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
4747
},
4848
},
49-
'60de77d5-dd2c-41ef-8c40-f8bb2381a359': {
50-
clientId: '60de77d5-dd2c-41ef-8c40-f8bb2381a359',
49+
/**
50+
* Form Fields
51+
*/
52+
'e4ef2896-8d90-4abd-bf0f-7b8034995927': {
53+
clientId: 'e4ef2896-8d90-4abd-bf0f-7b8034995927',
5154
redirectUri: window.location.origin + '/',
5255
scope: 'openid profile email name revoke',
5356
serverConfig: {
5457
wellknown:
55-
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
58+
'https://auth.pingone.ca/356a254c-cba3-4ade-be1a-860136e8df01/as/.well-known/openid-configuration',
5659
},
5760
},
5861
/**

e2e/davinci-suites/src/form-fields.test.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -10,9 +10,9 @@ import { asyncEvents } from './utils/async-events.js';
1010

1111
test('Should render form fields', async ({ page }) => {
1212
const { navigate } = asyncEvents(page);
13-
await navigate('/?clientId=60de77d5-dd2c-41ef-8c40-f8bb2381a359');
13+
await navigate('/?clientId=e4ef2896-8d90-4abd-bf0f-7b8034995927');
1414

15-
await expect(page.getByText('Select Test Form')).toBeVisible();
15+
await expect(page.getByText('Select Form Fields Test Form')).toBeVisible();
1616
await page.getByRole('button', { name: 'Form Fields' }).click();
1717

1818
await expect(page.getByText('Form Fields Test')).toBeVisible();
@@ -34,6 +34,39 @@ test('Should render form fields', async ({ page }) => {
3434
await page.locator('#phone-number-input-1').fill('1234567890');
3535
await page.locator('#extension-input-1').fill('7890');
3636

37+
// Rich text should render a link
38+
await expect(page.getByRole('link', { name: 'Ping Identity' })).toBeVisible();
39+
await expect(page.getByRole('link', { name: 'Ping Identity' })).toHaveAttribute(
40+
'href',
41+
'https://www.pingidentity.com',
42+
);
43+
44+
// Agreement title and content should be visible
45+
await expect(page.getByRole('heading', { name: 'Terms of Service Agreement' })).toBeVisible();
46+
await expect(
47+
page.getByText(
48+
'This is example agreement text, you can edit this text in the agreements section.',
49+
),
50+
).toBeVisible();
51+
52+
// Single checkbox default value
53+
await expect(page.locator('#single-checkbox-field')).not.toBeChecked();
54+
55+
// Single checkbox rich text
56+
await expect(page.getByText('I agree to the Terms and Conditions')).toBeVisible();
57+
await expect(page.getByRole('link', { name: 'Terms and Conditions' })).toBeVisible();
58+
await expect(page.getByRole('link', { name: 'Terms and Conditions' })).toHaveAttribute(
59+
'href',
60+
'https://www.pingidentity.com',
61+
);
62+
63+
// Toggle the single checkbox and assert that it is optional by the absence of an error message
64+
await page.locator('#single-checkbox-field').check();
65+
await expect(page.locator('#single-checkbox-field')).toBeChecked();
66+
await page.locator('#single-checkbox-field').uncheck();
67+
await expect(page.locator('#single-checkbox-field')).not.toBeChecked();
68+
await expect(page.locator('.single-checkbox-field-error')).not.toBeAttached();
69+
3770
await expect(page.getByRole('button', { name: 'Flow Button' })).toBeVisible();
3871
await expect(page.getByRole('button', { name: 'Flow Link' })).toBeVisible();
3972

@@ -53,20 +86,21 @@ test('Should render form fields', async ({ page }) => {
5386
'checkbox-field-key': ['option1 value', 'option2 value'],
5487
'dropdown-field-key': 'dropdown-option2-value',
5588
'radio-group-key': 'option2 value',
56-
'single-checkbox-field': false,
5789
'combobox-field-key': ['option1 value', 'option3 value'],
5890
'phone-field': {
5991
phoneNumber: '1234567890',
6092
countryCode: 'GB',
6193
extension: '7890', // Tests PhoneNumberExtensionCollector
6294
},
95+
'single-checkbox-field': false,
6396
});
6497
});
6598

6699
test('should render form validation fields', async ({ page }) => {
67-
await page.goto('http://localhost:5829/?clientId=60de77d5-dd2c-41ef-8c40-f8bb2381a359');
100+
const { navigate } = asyncEvents(page);
101+
await navigate('/?clientId=e4ef2896-8d90-4abd-bf0f-7b8034995927');
68102

69-
await expect(page.getByText('Select Test Form')).toBeVisible();
103+
await expect(page.getByText('Select Form Fields Test Form')).toBeVisible();
70104

71105
await page.getByRole('button', { name: 'Form Validation' }).click();
72106

@@ -80,4 +114,9 @@ test('should render form validation fields', async ({ page }) => {
80114

81115
await page.getByRole('textbox', { name: 'Email Address' }).fill('abc@email.com');
82116
await expect(page.getByText('Not a valid email')).not.toBeVisible();
117+
118+
// Toggle the single checkbox to assert error message
119+
await page.locator('#single-checkbox-field').check();
120+
await page.locator('#single-checkbox-field').uncheck();
121+
await expect(page.getByText('Select the checkbox to continue.')).toBeVisible();
83122
});

0 commit comments

Comments
 (0)