diff --git a/.changeset/single-checkbox.md b/.changeset/single-checkbox.md index 3c9aca1b03..819f7671eb 100644 --- a/.changeset/single-checkbox.md +++ b/.changeset/single-checkbox.md @@ -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` and `ValidatedSingleValueCollectorWithValue` 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` — so validators receive the value type that matches their collector (e.g. `boolean` for `ValidatedBooleanCollector`, `string` for text collectors) rather than always `string` diff --git a/.gitignore b/.gitignore index 97ddd27147..7fd194b0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,5 @@ GEMINI.md # Polaris .polaris-setup-progress.json +.polaris +.playwright-mcp diff --git a/e2e/davinci-app/components/boolean.ts b/e2e/davinci-app/components/boolean.ts index 4861b2819a..e1e6fd4ade 100644 --- a/e2e/davinci-app/components/boolean.ts +++ b/e2e/davinci-app/components/boolean.ts @@ -4,11 +4,16 @@ * 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 */ @@ -16,36 +21,59 @@ export default function booleanComponent( formEl: HTMLFormElement, collector: ValidatedBooleanCollector, updater: Updater, + validator: Validator, ) { - // 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); + } + } }); wrapper.appendChild(checkbox); diff --git a/e2e/davinci-app/components/label.ts b/e2e/davinci-app/components/label.ts index f62bdc5e38..d1c709a58a 100644 --- a/e2e/davinci-app/components/label.ts +++ b/e2e/davinci-app/components/label.ts @@ -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, @@ -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); } diff --git a/e2e/davinci-app/components/text.ts b/e2e/davinci-app/components/text.ts index 5bc6d71c31..ef4fd6a6c2 100644 --- a/e2e/davinci-app/components/text.ts +++ b/e2e/davinci-app/components/text.ts @@ -16,7 +16,7 @@ export default function textComponent( formEl: HTMLFormElement, collector: TextCollector | ValidatedTextCollector, updater: Updater, - validator: Validator, + validator: Validator, ) { const collectorKey = dotToCamelCase(collector.output.key); const label = document.createElement('label'); diff --git a/e2e/davinci-app/helper.ts b/e2e/davinci-app/helper.ts index 60a4655f07..ccfe15dbab 100644 --- a/e2e/davinci-app/helper.ts +++ b/e2e/davinci-app/helper.ts @@ -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('.') @@ -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; +} diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 0e1b8b27d9..75992ffd2c 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -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), + ); } }); diff --git a/e2e/davinci-app/server-configs.ts b/e2e/davinci-app/server-configs.ts index 2ddbc19c87..59deb0d902 100644 --- a/e2e/davinci-app/server-configs.ts +++ b/e2e/davinci-app/server-configs.ts @@ -46,13 +46,16 @@ export const serverConfigs: Record = { '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', }, }, /** diff --git a/e2e/davinci-suites/src/form-fields.test.ts b/e2e/davinci-suites/src/form-fields.test.ts index 33d81b3d8b..06336bdb12 100644 --- a/e2e/davinci-suites/src/form-fields.test.ts +++ b/e2e/davinci-suites/src/form-fields.test.ts @@ -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(); @@ -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(); @@ -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(); @@ -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(); }); diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index ce4a9d9b7b..64532411d4 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -284,13 +284,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -302,22 +300,22 @@ export function davinci(input: { description?: string; name?: string; status: "error"; + } | { + status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; state?: string; }; status: "success"; - } | { - status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { - status: "start"; - } | { _links?: Links; id?: string; interactionId?: string; @@ -335,20 +333,22 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; + href?: string; id?: string; interactionId?: string; interactionToken?: string; - href?: string; - session?: string; - status: "success"; + status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; + href?: string; + session?: string; + status: "success"; } | null; cache: { getLatestResponse: () => ((state: RootState< { @@ -1187,8 +1187,8 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(ValidatedTextCollector | ValidatedBooleanCollector | ValidatedPasswordCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | TextCollector | PasswordCollector | SingleSelectCollector | UnknownCollector | IdpCollector | FlowCollector | SubmitCollector | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector"> | MultiSelectCollector)[]> & { - getInitialState: () => (ValidatedTextCollector | ValidatedBooleanCollector | ValidatedPasswordCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | TextCollector | PasswordCollector | SingleSelectCollector | UnknownCollector | IdpCollector | FlowCollector | SubmitCollector | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector"> | MultiSelectCollector)[]; +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">)[]> & { + 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">)[]; }; // @public (undocumented) @@ -1670,9 +1670,9 @@ export type SingleCheckboxField = { key: string; label: string; required: boolean; - validation?: { - errorMessage: string; - }; + errorMessage?: string; + appearance: string; + richContent?: RichContent; }; // @public (undocumented) @@ -1926,7 +1926,13 @@ index?: number; export type Updater = (value: CollectorValueType, index?: number) => InternalErrorResponse | null; // @public (undocumented) -export type ValidatedBooleanCollector = ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean>; +export interface ValidatedBooleanCollector extends ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean> { + // (undocumented) + output: ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean>['output'] & { + appearance: string; + richContent?: CollectorRichContent; + }; +} // @public (undocumented) export type ValidatedField = { diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 87ac710a3b..44767539e0 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -284,13 +284,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -302,22 +300,22 @@ export function davinci(input: { description?: string; name?: string; status: "error"; + } | { + status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; state?: string; }; status: "success"; - } | { - status: "failure"; } | null; getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { - status: "start"; - } | { _links?: Links; id?: string; interactionId?: string; @@ -335,20 +333,22 @@ export function davinci(input: { } | { _links?: Links; eventName?: string; + href?: string; id?: string; interactionId?: string; interactionToken?: string; - href?: string; - session?: string; - status: "success"; + status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; - href?: string; id?: string; interactionId?: string; interactionToken?: string; - status: "failure"; + href?: string; + session?: string; + status: "success"; } | null; cache: { getLatestResponse: () => ((state: RootState< { @@ -1184,8 +1184,8 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(ValidatedTextCollector | ValidatedBooleanCollector | ValidatedPasswordCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | TextCollector | PasswordCollector | SingleSelectCollector | UnknownCollector | IdpCollector | FlowCollector | SubmitCollector | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector"> | MultiSelectCollector)[]> & { - getInitialState: () => (ValidatedTextCollector | ValidatedBooleanCollector | ValidatedPasswordCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | TextCollector | PasswordCollector | SingleSelectCollector | UnknownCollector | IdpCollector | FlowCollector | SubmitCollector | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector"> | MultiSelectCollector)[]; +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">)[]> & { + 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">)[]; }; // @public (undocumented) @@ -1667,9 +1667,9 @@ export type SingleCheckboxField = { key: string; label: string; required: boolean; - validation?: { - errorMessage: string; - }; + errorMessage?: string; + appearance: string; + richContent?: RichContent; }; // @public (undocumented) @@ -1923,7 +1923,13 @@ index?: number; export type Updater = (value: CollectorValueType, index?: number) => InternalErrorResponse | null; // @public (undocumented) -export type ValidatedBooleanCollector = ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean>; +export interface ValidatedBooleanCollector extends ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean> { + // (undocumented) + output: ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean>['output'] & { + appearance: string; + richContent?: CollectorRichContent; + }; +} // @public (undocumented) export type ValidatedField = { diff --git a/packages/davinci-client/src/lib/collector.types.test-d.ts b/packages/davinci-client/src/lib/collector.types.test-d.ts index fdd1209a8a..4f63aabe0c 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -434,6 +434,7 @@ describe('Collector Types', () => { label: 'Accept terms', type: 'boolean', value: false, + appearance: 'CHECKBOX', }, }; diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index 996fe6d20d..c8fe118865 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -93,6 +93,16 @@ export interface ValidatedSingleValueCollectorWithValue< }; } +export interface ValidatedBooleanCollector extends ValidatedSingleValueCollectorWithValue< + 'ValidatedBooleanCollector', + boolean +> { + output: ValidatedSingleValueCollectorWithValue<'ValidatedBooleanCollector', boolean>['output'] & { + appearance: string; + richContent?: CollectorRichContent; + }; +} + export interface SingleSelectCollectorWithValue { category: 'SingleValueCollector'; error: string | null; @@ -227,10 +237,6 @@ export interface ValidatedPasswordCollector { export type TextCollector = SingleValueCollectorWithValue<'TextCollector'>; export type SingleSelectCollector = SingleSelectCollectorWithValue<'SingleSelectCollector'>; export type ValidatedTextCollector = ValidatedSingleValueCollectorWithValue<'TextCollector'>; -export type ValidatedBooleanCollector = ValidatedSingleValueCollectorWithValue< - 'ValidatedBooleanCollector', - boolean ->; export type SingleValueCollectors = | ValidatedPasswordCollector diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index cbb8ce890a..b2398b8064 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -26,6 +26,7 @@ import { returnQrCodeCollector, returnAgreementCollector, normalizeReplacements, + returnValidatedBooleanCollector, } from './collector.utils.js'; import { returnPasswordPolicyValidator } from './password-policy.rules.js'; import type { @@ -43,6 +44,7 @@ import type { ReadOnlyField, RedirectField, RichContentReplacement, + SingleCheckboxField, StandardField, AgreementField, } from './davinci.types.js'; @@ -1781,3 +1783,53 @@ describe('returnPasswordPolicyValidator', () => { expect(result.length).toBeGreaterThanOrEqual(3); }); }); + +describe('returnValidatedBooleanCollector', () => { + it('should include appearance on output', () => { + const field: SingleCheckboxField = { + type: 'SINGLE_CHECKBOX', + inputType: 'BOOLEAN', + key: 'accept-terms', + label: 'Accept Terms', + required: false, + appearance: 'checkbox', + }; + const result = returnValidatedBooleanCollector(field, 0); + expect(result.output.appearance).toBe('checkbox'); + }); + + it('should include richContent on output when field has richContent', () => { + const field: SingleCheckboxField = { + type: 'SINGLE_CHECKBOX', + inputType: 'BOOLEAN', + key: 'accept-terms', + label: 'Accept Terms', + required: false, + appearance: 'checkbox', + richContent: { + content: 'I agree to the {{link}}', + replacements: { + link: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com/terms', + target: '_blank', + }, + }, + }, + }; + const result = returnValidatedBooleanCollector(field, 0); + expect(result.output.richContent).toEqual({ + content: 'I agree to the {{link}}', + replacements: [ + { + key: 'link', + type: 'link', + value: 'terms and conditions', + href: 'https://example.com/terms', + target: '_blank', + }, + ], + }); + }); +}); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index e144c4224b..c3c97cfbe8 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -63,6 +63,27 @@ import type { PhoneNumberExtensionField, } from './davinci.types.js'; +/** + * @function normalizeReplacements - Flattens the API's keyed + * `Record` into an array of `RichContentLink` + * with the original key carried on each entry. Hrefs are passed through + * unmodified — consumers are responsible for sanitizing before rendering. + * + * @param {Record} replacements - The replacements map from the API. + * @returns {RichContentLink[]} The flattened array of replacement entries. + */ +export function normalizeReplacements( + replacements: Record, +): RichContentLink[] { + return Object.entries(replacements).map(([key, replacement]) => ({ + key, + type: replacement.type, + value: replacement.value, + href: replacement.href, + ...(replacement.target && { target: replacement.target }), + })); +} + /** * @function returnActionCollector - Creates an ActionCollector object based on the provided field and index. * @param {DaVinciField} field - The field object containing key, label, type, and links. @@ -258,12 +279,19 @@ export function returnSingleValueCollector< if ('required' in field && field.required === true) { validationArray.push({ type: 'required', - message: - ('validation' in field && field.validation?.errorMessage) || 'Value cannot be empty', + message: ('errorMessage' in field && field.errorMessage) || 'Value cannot be empty', rule: true, }); } + const richContent = + 'richContent' in field && field.richContent + ? { + content: field.richContent.content, + replacements: normalizeReplacements(field.richContent.replacements ?? {}), + } + : undefined; + return { category: 'ValidatedSingleValueCollector', error: error || null, @@ -281,6 +309,8 @@ export function returnSingleValueCollector< label: field.label, type: field.type, value: false, + appearance: ('appearance' in field && field.appearance) || '', + ...(richContent && { richContent }), }, } as InferSingleValueCollectorType<'ValidatedBooleanCollector'>; } else if ('validation' in field || 'required' in field) { @@ -561,8 +591,15 @@ export function returnSingleSelectCollector(field: SingleSelectField, idx: numbe * @param {number} idx - The index to be used in the id of the ValidatedBooleanCollector. * @returns {ValidatedBooleanCollector} The constructed ValidatedBooleanCollector object. */ -export function returnValidatedBooleanCollector(field: SingleCheckboxField, idx: number) { - return returnSingleValueCollector(field, idx, 'ValidatedBooleanCollector'); +export function returnValidatedBooleanCollector( + field: SingleCheckboxField, + idx: number, +): ValidatedBooleanCollector { + return returnSingleValueCollector( + field, + idx, + 'ValidatedBooleanCollector', + ) as ValidatedBooleanCollector; } /** @@ -856,27 +893,6 @@ export function returnObjectValueCollector( ); } -/** - * @function normalizeReplacements - Flattens the API's keyed - * `Record` into an array of `RichContentLink` - * with the original key carried on each entry. Hrefs are passed through - * unmodified — consumers are responsible for sanitizing before rendering. - * - * @param {Record} replacements - The replacements map from the API. - * @returns {RichContentLink[]} The flattened array of replacement entries. - */ -export function normalizeReplacements( - replacements: Record, -): RichContentLink[] { - return Object.entries(replacements).map(([key, replacement]) => ({ - key, - type: replacement.type, - value: replacement.value, - href: replacement.href, - ...(replacement.target && { target: replacement.target }), - })); -} - /** * @function returnNoValueCollector - Creates a NoValueCollector object based on the provided field, index, and optional collector type. * @param {DaVinciField} field - The field object containing key, label, type, and links. diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 23f80ad719..8c75a90130 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -177,9 +177,9 @@ export type SingleCheckboxField = { key: string; label: string; required: boolean; - validation?: { - errorMessage: string; - }; + errorMessage?: string; + appearance: string; + richContent?: RichContent; }; export type SingleSelectField = { diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index 3db2cbdfcb..a3cc595be9 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -1643,6 +1643,7 @@ describe('The node collector reducer with ValidatedBooleanCollector', () => { label: 'Accept Terms', type: 'SINGLE_CHECKBOX', value: false, + appearance: '', }, } satisfies ValidatedBooleanCollector, ]); @@ -1683,6 +1684,7 @@ describe('The node collector reducer with ValidatedBooleanCollector', () => { label: 'Accept Terms', type: 'SINGLE_CHECKBOX', value: false, + appearance: '', }, } satisfies ValidatedBooleanCollector, ]); @@ -1699,7 +1701,7 @@ describe('The node collector reducer with ValidatedBooleanCollector', () => { key: 'accept-terms', label: 'Accept Terms', required: true, - validation: { errorMessage: 'You must accept the terms' }, + errorMessage: 'You must accept the terms', }, ], formData: {}, @@ -1738,6 +1740,7 @@ describe('The node collector reducer with ValidatedBooleanCollector', () => { label: 'Accept Terms', type: 'SINGLE_CHECKBOX', value: false, + appearance: 'checkbox', }, }, ]; @@ -1759,10 +1762,76 @@ describe('The node collector reducer with ValidatedBooleanCollector', () => { label: 'Accept Terms', type: 'SINGLE_CHECKBOX', value: false, + appearance: 'checkbox', }, }, ]); }); + + it('should normalise richContent replacements from Record to RichContentLink[]', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'SINGLE_CHECKBOX', + inputType: 'BOOLEAN', + key: 'accept-terms', + label: 'Accept Terms', + required: false, + appearance: 'checkbox', + richContent: { + content: 'I agree to the {{tos}}', + replacements: { + tos: { + type: 'link', + value: 'Terms of Service', + href: 'https://example.com/tos', + target: '_blank', + }, + }, + }, + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result).toEqual([ + { + category: 'ValidatedSingleValueCollector', + error: null, + type: 'ValidatedBooleanCollector', + id: 'accept-terms-0', + name: 'accept-terms', + input: { + key: 'accept-terms', + value: false, + type: 'SINGLE_CHECKBOX', + validation: [], + }, + output: { + key: 'accept-terms', + label: 'Accept Terms', + type: 'SINGLE_CHECKBOX', + value: false, + appearance: 'checkbox', + richContent: { + content: 'I agree to the {{tos}}', + replacements: [ + { + key: 'tos', + type: 'link', + value: 'Terms of Service', + href: 'https://example.com/tos', + target: '_blank', + }, + ], + }, + }, + } satisfies ValidatedBooleanCollector, + ]); + }); }); describe('The node collector reducer with FidoAuthenticationFieldValue', () => {