Skip to content

Commit fa84047

Browse files
committed
feat(davinci-client): support rich text and appearance in single checkbox
1 parent afc0584 commit fa84047

13 files changed

Lines changed: 291 additions & 96 deletions

File tree

.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: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
Updater,
1010
Validator,
1111
} from '@forgerock/davinci-client/types';
12-
import { dotToCamelCase } from '../helper.js';
12+
import { dotToCamelCase, richContentInterpolation } from '../helper.js';
1313

1414
/**
1515
* Creates a single checkbox and attaches it to the form
@@ -36,34 +36,43 @@ export default function booleanComponent(
3636
const checkbox = document.createElement('input');
3737
checkbox.type = 'checkbox';
3838
checkbox.id = collectorKey;
39-
checkbox.name = collectorKey || 'single-checkbox-field';
39+
checkbox.name = collectorKey;
4040
checkbox.checked = collector.output.value;
4141
checkbox.value = 'checked';
4242

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

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

53-
// Keep collector state aligned with the current UI value
54-
const updateError = updater(checked);
55-
if (updateError && 'error' in updateError) {
56-
console.error(updateError.error.message);
57-
}
58-
5962
// Validate the input
6063
if (Array.isArray(result) && result.length && !errorEl) {
61-
const errorEl = document.createElement('div');
62-
errorEl.className = `${collectorKey}-error`;
63-
errorEl.innerText = result.join(', ');
64-
formEl?.querySelector(`#${collectorKey}`)?.after(errorEl);
64+
const newErrorEl = document.createElement('div');
65+
newErrorEl.className = `${collectorKey}-error`;
66+
newErrorEl.innerText = result.join(', ');
67+
formEl?.querySelector(`#${collectorKey}`)?.after(newErrorEl);
68+
} else if (Array.isArray(result) && result.length) {
69+
return;
6570
} else {
6671
formEl.querySelector(`.${collectorKey}-error`)?.remove();
72+
const updateError = updater(checked);
73+
if (updateError && 'error' in updateError) {
74+
console.error(updateError.error.message);
75+
}
6776
}
6877
});
6978

e2e/davinci-app/components/label.ts

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
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/helper.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { CollectorRichContent } from '@forgerock/davinci-client';
2+
13
/*
24
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
35
*
@@ -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-suites/src/form-fields.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,16 @@ test('Should render form fields', async ({ page }) => {
5151

5252
// Single checkbox default value
5353
await expect(page.locator('#single-checkbox-field')).not.toBeChecked();
54+
55+
// Single checkbox rich text
5456
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+
);
5562

56-
// Toggle the single checkbox and assert that it is optional by the abscence of an error message
63+
// Toggle the single checkbox and assert that it is optional by the absence of an error message
5764
await page.locator('#single-checkbox-field').check();
5865
await expect(page.locator('#single-checkbox-field')).toBeChecked();
5966
await page.locator('#single-checkbox-field').uncheck();
@@ -90,7 +97,8 @@ test('Should render form fields', async ({ page }) => {
9097
});
9198

9299
test('should render form validation fields', async ({ page }) => {
93-
await page.goto('http://localhost:5829/?clientId=e4ef2896-8d90-4abd-bf0f-7b8034995927');
100+
const { navigate } = asyncEvents(page);
101+
await navigate('/?clientId=e4ef2896-8d90-4abd-bf0f-7b8034995927');
94102

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

packages/davinci-client/api-report/davinci-client.api.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -382,14 +382,14 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
382382
} & Omit<{
383383
requestId: string;
384384
data?: unknown;
385-
error?: FetchBaseQueryError | SerializedError | undefined;
385+
error?: SerializedError | FetchBaseQueryError | undefined;
386386
endpointName: string;
387387
startedTimeStamp: number;
388388
fulfilledTimeStamp?: number;
389389
}, "data" | "fulfilledTimeStamp"> & Required<Pick<{
390390
requestId: string;
391391
data?: unknown;
392-
error?: FetchBaseQueryError | SerializedError | undefined;
392+
error?: SerializedError | FetchBaseQueryError | undefined;
393393
endpointName: string;
394394
startedTimeStamp: number;
395395
fulfilledTimeStamp?: number;
@@ -406,7 +406,7 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
406406
} & {
407407
requestId: string;
408408
data?: unknown;
409-
error?: FetchBaseQueryError | SerializedError | undefined;
409+
error?: SerializedError | FetchBaseQueryError | undefined;
410410
endpointName: string;
411411
startedTimeStamp: number;
412412
fulfilledTimeStamp?: number;
@@ -423,14 +423,14 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
423423
} & Omit<{
424424
requestId: string;
425425
data?: unknown;
426-
error?: FetchBaseQueryError | SerializedError | undefined;
426+
error?: SerializedError | FetchBaseQueryError | undefined;
427427
endpointName: string;
428428
startedTimeStamp: number;
429429
fulfilledTimeStamp?: number;
430430
}, "error"> & Required<Pick<{
431431
requestId: string;
432432
data?: unknown;
433-
error?: FetchBaseQueryError | SerializedError | undefined;
433+
error?: SerializedError | FetchBaseQueryError | undefined;
434434
endpointName: string;
435435
startedTimeStamp: number;
436436
fulfilledTimeStamp?: number;
@@ -477,14 +477,14 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
477477
} & Omit<{
478478
requestId: string;
479479
data?: unknown;
480-
error?: FetchBaseQueryError | SerializedError | undefined;
480+
error?: SerializedError | FetchBaseQueryError | undefined;
481481
endpointName: string;
482482
startedTimeStamp: number;
483483
fulfilledTimeStamp?: number;
484484
}, "data" | "fulfilledTimeStamp"> & Required<Pick<{
485485
requestId: string;
486486
data?: unknown;
487-
error?: FetchBaseQueryError | SerializedError | undefined;
487+
error?: SerializedError | FetchBaseQueryError | undefined;
488488
endpointName: string;
489489
startedTimeStamp: number;
490490
fulfilledTimeStamp?: number;
@@ -501,7 +501,7 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
501501
} & {
502502
requestId: string;
503503
data?: unknown;
504-
error?: FetchBaseQueryError | SerializedError | undefined;
504+
error?: SerializedError | FetchBaseQueryError | undefined;
505505
endpointName: string;
506506
startedTimeStamp: number;
507507
fulfilledTimeStamp?: number;
@@ -518,14 +518,14 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
518518
} & Omit<{
519519
requestId: string;
520520
data?: unknown;
521-
error?: FetchBaseQueryError | SerializedError | undefined;
521+
error?: SerializedError | FetchBaseQueryError | undefined;
522522
endpointName: string;
523523
startedTimeStamp: number;
524524
fulfilledTimeStamp?: number;
525525
}, "error"> & Required<Pick<{
526526
requestId: string;
527527
data?: unknown;
528-
error?: FetchBaseQueryError | SerializedError | undefined;
528+
error?: SerializedError | FetchBaseQueryError | undefined;
529529
endpointName: string;
530530
startedTimeStamp: number;
531531
fulfilledTimeStamp?: number;
@@ -1187,8 +1187,8 @@ value: Record<string, unknown>;
11871187
}, string>;
11881188

11891189
// @public
1190-
export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | PasswordCollector | ValidatedPasswordCollector | ValidatedBooleanCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | UnknownCollector | IdpCollector | FlowCollector | SubmitCollector | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector | MultiSelectCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & {
1191-
getInitialState: () => (TextCollector | SingleSelectCollector | PasswordCollector | ValidatedPasswordCollector | ValidatedBooleanCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | UnknownCollector | IdpCollector | FlowCollector | SubmitCollector | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector | MultiSelectCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[];
1190+
export const nodeCollectorReducer: Reducer<(ValidatedBooleanCollector | TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | RichTextCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & {
1191+
getInitialState: () => (ValidatedBooleanCollector | TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | ValidatedPasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | RichTextCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[];
11921192
};
11931193

11941194
// @public (undocumented)
@@ -1671,6 +1671,8 @@ export type SingleCheckboxField = {
16711671
label: string;
16721672
required: boolean;
16731673
errorMessage?: string;
1674+
appearance: string;
1675+
richContent?: RichContent;
16741676
};
16751677

16761678
// @public (undocumented)
@@ -1924,7 +1926,13 @@ index?: number;
19241926
export type Updater<T = unknown> = (value: CollectorValueType<T>, index?: number) => InternalErrorResponse | null;
19251927

19261928
// @public (undocumented)
1927-
export type ValidatedBooleanCollector = ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean>;
1929+
export interface ValidatedBooleanCollector extends ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean> {
1930+
// (undocumented)
1931+
output: ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean>['output'] & {
1932+
appearance: string;
1933+
richContent?: CollectorRichContent;
1934+
};
1935+
}
19281936

19291937
// @public (undocumented)
19301938
export type ValidatedField = {

0 commit comments

Comments
 (0)