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
4 changes: 3 additions & 1 deletion .changeset/single-checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
'@forgerock/davinci-client': minor
---

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

**Type improvements**

- `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

- `ValidatedBooleanCollector` interface extends `ValidatedSingleValueCollectorWithValue`, intersecting `output` with `appearance: string` and `richContent?: CollectorRichContent`

- `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`
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,5 @@ GEMINI.md

# Polaris
.polaris-setup-progress.json
.polaris
.playwright-mcp
58 changes: 43 additions & 15 deletions e2e/davinci-app/components/boolean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,48 +4,76 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
import type { ValidatedBooleanCollector, Updater } from '@forgerock/davinci-client/types';
import type {
ValidatedBooleanCollector,
Updater,
Validator,
} from '@forgerock/davinci-client/types';
import { dotToCamelCase, richContentInterpolation } from '../helper.js';

/**
* Creates a single checkbox and attaches it to the form
* @param {HTMLFormElement} formEl - The form element to attach the checkboxes to
* @param {HTMLFormElement} formEl - The form element to attach the checkbox to
* @param {ValidatedBooleanCollector} collector - Contains the configuration
* @param {Updater} updater - Function to call when selection changes
*/
export default function booleanComponent(
formEl: HTMLFormElement,
collector: ValidatedBooleanCollector,
updater: Updater<ValidatedBooleanCollector>,
validator: Validator<ValidatedBooleanCollector>,
) {
// Create a container for the checkboxes
const collectorKey = dotToCamelCase(collector.output.key);

// Create a container for the checkbox
const containerDiv = document.createElement('div');
containerDiv.className = 'single-checkbox-container';

// Create a heading/label for the checkbox group
const groupLabel = document.createElement('div');
groupLabel.textContent = collector.output.label || 'Single Checkbox';
groupLabel.className = 'single-checkbox-label';
containerDiv.appendChild(groupLabel);

// Create checkboxes for each option
// Create a single checkbox
const wrapper = document.createElement('div');
wrapper.className = 'checkbox-wrapper';

const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = collector.output.key;
checkbox.name = collector.output.key || 'single-checkbox-field';
checkbox.id = collectorKey;
checkbox.name = collectorKey;
checkbox.checked = collector.output.value;
checkbox.value = 'checked';

const label = document.createElement('label');
label.htmlFor = checkbox.id;
label.textContent = collector.output.label;

const { richContent } = collector.output;
if (!richContent || richContent.replacements.length === 0) {
label.textContent = collector.output.label;
} else {
const pRichText = richContentInterpolation(richContent);
while (pRichText.firstChild) {
label.appendChild(pRichText.firstChild);
}
}

// Add event listener to handle single-select behavior
checkbox.addEventListener('change', (event) => {
const target = event.target as HTMLInputElement;
updater(target.checked);
const checked = (event.target as HTMLInputElement).checked;
const result = validator(checked);
const errorEl = formEl?.querySelector(`.${collectorKey}-error`);

// Validate the input
if (Array.isArray(result) && result.length && !errorEl) {
const newErrorEl = document.createElement('div');
newErrorEl.className = `${collectorKey}-error`;
newErrorEl.innerText = result.join(', ');
formEl?.querySelector(`#${collectorKey}`)?.after(newErrorEl);
} else if (Array.isArray(result) && result.length) {
return;
} else {
formEl.querySelector(`.${collectorKey}-error`)?.remove();
const updateError = updater(checked);
if (updateError && 'error' in updateError) {
console.error(updateError.error.message);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

wrapper.appendChild(checkbox);
Expand Down
30 changes: 3 additions & 27 deletions e2e/davinci-app/components/label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* of the MIT license. See the LICENSE file for details.
*/
import type { ReadOnlyCollector, RichTextCollector } from '@forgerock/davinci-client/types';
import { richContentInterpolation } from '../helper.js';

export default function (
formEl: HTMLFormElement,
Expand All @@ -28,32 +29,7 @@ export default function (
}

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

for (let i = 0; i < segments.length; i++) {
if (i % 2 === 0) {
// Text segment
if (segments[i]) {
p.appendChild(document.createTextNode(segments[i]));
}
} else {
// Replacement key
const replacement = replacementMap.get(segments[i]);
if (replacement?.type === 'link') {
const a = document.createElement('a');
a.href = replacement.href;
a.textContent = replacement.value;
if (replacement.target) {
a.target = replacement.target;
if (replacement.target === '_blank') {
a.rel = 'noopener noreferrer';
}
}
p.appendChild(a);
}
}
}

formEl?.appendChild(p);
formEl?.appendChild(pRichText);
}
2 changes: 1 addition & 1 deletion e2e/davinci-app/components/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function textComponent(
formEl: HTMLFormElement,
collector: TextCollector | ValidatedTextCollector,
updater: Updater<TextCollector | ValidatedTextCollector>,
validator: Validator,
validator: Validator<ValidatedTextCollector>,
) {
const collectorKey = dotToCamelCase(collector.output.key);
const label = document.createElement('label');
Expand Down
39 changes: 38 additions & 1 deletion e2e/davinci-app/helper.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/*
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2025 - 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 type { CollectorRichContent } from '@forgerock/davinci-client';

export function dotToCamelCase(str: string) {
return str
.split('.')
Expand All @@ -12,3 +14,38 @@ export function dotToCamelCase(str: string) {
)
.join('');
}

// Interpolate the template by splitting on {{key}} and inserting links
export function richContentInterpolation(richContent: CollectorRichContent): HTMLParagraphElement {
const p = document.createElement('p');
p.style.whiteSpace = 'pre-line';

const segments = richContent.content.split(/\{\{(\w+)\}\}/);
const replacementMap = new Map(richContent.replacements.map((r) => [r.key, r]));

for (let i = 0; i < segments.length; i++) {
if (i % 2 === 0) {
// Text segment
if (segments[i]) {
p.appendChild(document.createTextNode(segments[i]));
}
} else {
// Replacement key
const replacement = replacementMap.get(segments[i]);
if (replacement?.type === 'link') {
const a = document.createElement('a');
a.href = replacement.href;
a.textContent = replacement.value;
if (replacement.target) {
a.target = replacement.target;
if (replacement.target === '_blank') {
a.rel = 'noopener noreferrer';
}
}
p.appendChild(a);
}
}
}

return p;
}
7 changes: 6 additions & 1 deletion e2e/davinci-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,12 @@ const urlParams = new URLSearchParams(window.location.search);
} else if (collector.type === 'MultiSelectCollector') {
multiValueComponent(formEl, collector, davinciClient.update(collector));
} else if (collector.type === 'ValidatedBooleanCollector') {
booleanComponent(formEl, collector, davinciClient.update(collector));
booleanComponent(
formEl,
collector,
davinciClient.update(collector),
davinciClient.validate(collector),
);
}
});

Expand Down
9 changes: 6 additions & 3 deletions e2e/davinci-app/server-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,16 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
},
},
'60de77d5-dd2c-41ef-8c40-f8bb2381a359': {
clientId: '60de77d5-dd2c-41ef-8c40-f8bb2381a359',
/**
* Form Fields
*/
'e4ef2896-8d90-4abd-bf0f-7b8034995927': {
clientId: 'e4ef2896-8d90-4abd-bf0f-7b8034995927',
redirectUri: window.location.origin + '/',
scope: 'openid profile email name revoke',
serverConfig: {
wellknown:
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
'https://auth.pingone.ca/356a254c-cba3-4ade-be1a-860136e8df01/as/.well-known/openid-configuration',
},
},
/**
Expand Down
49 changes: 44 additions & 5 deletions e2e/davinci-suites/src/form-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { asyncEvents } from './utils/async-events.js';

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

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

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

// Rich text should render a link
await expect(page.getByRole('link', { name: 'Ping Identity' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Ping Identity' })).toHaveAttribute(
'href',
'https://www.pingidentity.com',
);

// Agreement title and content should be visible
await expect(page.getByRole('heading', { name: 'Terms of Service Agreement' })).toBeVisible();
await expect(
page.getByText(
'This is example agreement text, you can edit this text in the agreements section.',
),
).toBeVisible();

// Single checkbox default value
await expect(page.locator('#single-checkbox-field')).not.toBeChecked();

// Single checkbox rich text
await expect(page.getByText('I agree to the Terms and Conditions')).toBeVisible();
await expect(page.getByRole('link', { name: 'Terms and Conditions' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Terms and Conditions' })).toHaveAttribute(
'href',
'https://www.pingidentity.com',
);

// Toggle the single checkbox and assert that it is optional by the absence of an error message
await page.locator('#single-checkbox-field').check();
await expect(page.locator('#single-checkbox-field')).toBeChecked();
await page.locator('#single-checkbox-field').uncheck();
await expect(page.locator('#single-checkbox-field')).not.toBeChecked();
await expect(page.locator('.single-checkbox-field-error')).not.toBeAttached();

await expect(page.getByRole('button', { name: 'Flow Button' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Flow Link' })).toBeVisible();

Expand All @@ -53,20 +86,21 @@ test('Should render form fields', async ({ page }) => {
'checkbox-field-key': ['option1 value', 'option2 value'],
'dropdown-field-key': 'dropdown-option2-value',
'radio-group-key': 'option2 value',
'single-checkbox-field': false,
'combobox-field-key': ['option1 value', 'option3 value'],
'phone-field': {
phoneNumber: '1234567890',
countryCode: 'GB',
extension: '7890', // Tests PhoneNumberExtensionCollector
},
'single-checkbox-field': false,
});
});

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

await expect(page.getByText('Select Test Form')).toBeVisible();
await expect(page.getByText('Select Form Fields Test Form')).toBeVisible();

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

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

await page.getByRole('textbox', { name: 'Email Address' }).fill('abc@email.com');
await expect(page.getByText('Not a valid email')).not.toBeVisible();

// Toggle the single checkbox to assert error message
await page.locator('#single-checkbox-field').check();
await page.locator('#single-checkbox-field').uncheck();
await expect(page.getByText('Select the checkbox to continue.')).toBeVisible();
});
Loading
Loading