diff --git a/.changeset/silent-ideas-joke.md b/.changeset/silent-ideas-joke.md new file mode 100644 index 0000000000..cd2c44f1ca --- /dev/null +++ b/.changeset/silent-ideas-joke.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +Support form agreements with AgreementCollector diff --git a/e2e/davinci-app/components/agreement.ts b/e2e/davinci-app/components/agreement.ts new file mode 100644 index 0000000000..002658aa5b --- /dev/null +++ b/e2e/davinci-app/components/agreement.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 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 { AgreementCollector } from '@forgerock/davinci-client/types'; + +export default function (formEl: HTMLFormElement, collector: AgreementCollector) { + const output = collector.output; + const componentEnabled = output.enabled; + + if (!componentEnabled) { + return; + } + + const content = output.label; + const titleEnabled = output.titleEnabled; + const title = output.title; + + if (titleEnabled) { + const titleEl = document.createElement('h3'); + titleEl.innerText = title; + formEl?.appendChild(titleEl); + } + + const agreement = document.createElement('p'); + agreement.innerText = content; + formEl?.appendChild(agreement); +} diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 46ef800d5b..a1a93b54d4 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -33,6 +33,7 @@ import labelComponent from './components/label.js'; import objectValueComponent from './components/object-value.js'; import fidoComponent from './components/fido.js'; import qrCodeComponent from './components/qr-code.js'; +import agreementComponent from './components/agreement.js'; import pollingComponent from './components/polling.js'; const loggerFn = { @@ -227,6 +228,8 @@ const urlParams = new URLSearchParams(window.location.search); ); } else if (collector.type === 'QrCodeCollector') { qrCodeComponent(formEl, collector); + } else if (collector.type === 'AgreementCollector') { + agreementComponent(formEl, collector); } else if (collector.type === 'TextCollector') { textComponent( formEl, // You can ignore this; it's just for rendering 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 deafe24f8c..5fe63303ad 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -23,6 +23,10 @@ import type { InferSingleValueCollectorType, InferMultiValueCollectorType, InferActionCollectorType, + InferNoValueCollectorType, + ReadOnlyCollector, + QrCodeCollector, + AgreementCollector, } from './collector.types.js'; describe('Collector Types', () => { @@ -355,4 +359,65 @@ describe('Collector Types', () => { expectTypeOf(tCollector).toMatchTypeOf(); }); }); + + describe('InferNoValueCollectorType', () => { + it('should correctly infer ReadOnlyCollector Type', () => { + const tCollector: InferNoValueCollectorType<'ReadOnlyCollector'> = { + category: 'NoValueCollector', + error: null, + type: 'ReadOnlyCollector', + id: 'read-only-0', + name: 'read-only', + output: { + key: 'read-only', + label: 'Read Only Field', + type: 'READ_ONLY', + }, + }; + + expectTypeOf(tCollector).toEqualTypeOf(); + }); + + it('should correctly infer QrCodeCollector Type', () => { + const tCollector: InferNoValueCollectorType<'QrCodeCollector'> = { + category: 'NoValueCollector', + error: null, + type: 'QrCodeCollector', + id: 'qr-code-0', + name: 'qr-code-0', + output: { + key: 'qr-code-0', + label: 'FALLBACK TEXT', + type: 'QR_CODE', + src: 'data:image/png;base64,abc123', + }, + }; + + expectTypeOf(tCollector).toEqualTypeOf(); + }); + + it('should correctly infer AgreementCollector Type', () => { + const tCollector: InferNoValueCollectorType<'AgreementCollector'> = { + category: 'NoValueCollector', + error: null, + type: 'AgreementCollector', + id: 'agreement-0', + name: 'agreement-0', + output: { + key: 'agreement-0', + label: 'Please accept the terms and conditions', + type: 'AGREEMENT', + titleEnabled: true, + title: 'Terms and Conditions', + agreement: { + id: 'agreement-123', + useDynamicAgreement: false, + }, + enabled: true, + }, + }; + + expectTypeOf(tCollector).toEqualTypeOf(); + }); + }); }); diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index e6c882a5c4..ab1b5832df 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -482,7 +482,11 @@ export type SubmitCollector = ActionCollectorNoUrl<'SubmitCollector'>; /** * @interface NoValueCollector - Represents a collector that collects no value; text only for display. */ -export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector'; +export type NoValueCollectorTypes = + | 'ReadOnlyCollector' + | 'NoValueCollector' + | 'QrCodeCollector' + | 'AgreementCollector'; export interface NoValueCollectorBase { category: 'NoValueCollector'; @@ -511,6 +515,21 @@ export interface QrCodeCollectorBase { }; } +export interface AgreementCollector extends NoValueCollectorBase<'AgreementCollector'> { + output: { + key: string; + label: string; + type: string; + titleEnabled: boolean; + title: string; + agreement: { + id: string; + useDynamicAgreement: boolean; + }; + enabled: boolean; + }; +} + /** * Type to help infer the collector based on the collector type * Used specifically in the returnNoValueCollector wrapper function. @@ -523,12 +542,15 @@ export type InferNoValueCollectorType = ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase - : NoValueCollectorBase<'NoValueCollector'>; + : T extends 'AgreementCollector' + ? AgreementCollector + : NoValueCollectorBase<'NoValueCollector'>; export type NoValueCollectors = | NoValueCollectorBase<'NoValueCollector'> | NoValueCollectorBase<'ReadOnlyCollector'> - | QrCodeCollectorBase; + | QrCodeCollectorBase + | AgreementCollector; export type NoValueCollector = NoValueCollectorBase; @@ -536,6 +558,10 @@ export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; export type QrCodeCollector = QrCodeCollectorBase; +/** ********************************************************************* + * UNKNOWN COLLECTOR + */ + export type UnknownCollector = { category: 'UnknownCollector'; error: string | null; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index ebc33f3c6b..c9d1381857 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -23,6 +23,7 @@ import { returnSingleValueAutoCollector, returnObjectValueAutoCollector, returnQrCodeCollector, + returnAgreementCollector, } from './collector.utils.js'; import type { DaVinciField, @@ -37,6 +38,7 @@ import type { ReadOnlyField, RedirectField, StandardField, + AgreementField, } from './davinci.types.js'; import type { MultiSelectCollector, @@ -883,6 +885,49 @@ describe('returnQrCodeCollector', () => { }); }); +describe('returnAgreementCollector', () => { + it('should return a valid AgreementCollector with all fields', () => { + const mockField: AgreementField = { + type: 'AGREEMENT', + key: 'agreement-field', + content: 'Please accept the terms and conditions', + titleEnabled: true, + title: 'Terms and Conditions', + agreement: { + id: 'agreement-123', + useDynamicAgreement: false, + }, + enabled: true, + }; + const result = returnAgreementCollector(mockField, 0); + expect(result).toEqual({ + category: 'NoValueCollector', + error: null, + type: 'AgreementCollector', + id: 'agreement-field-0', + name: 'agreement-field-0', + output: { + key: 'agreement-field-0', + label: 'Please accept the terms and conditions', + type: 'AGREEMENT', + titleEnabled: true, + title: 'Terms and Conditions', + agreement: { + id: 'agreement-123', + useDynamicAgreement: false, + }, + enabled: true, + }, + }); + }); + + it('should set error when content is missing', () => { + const mockField = { type: 'AGREEMENT', key: 'agreement-field' } as unknown as AgreementField; + const result = returnAgreementCollector(mockField, 0); + expect(result.error).toContain('Content is not found'); + }); +}); + describe('returnSingleValueAutoCollector', () => { it('should create a valid ProtectCollector', () => { const mockField: ProtectField = { diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index bbbdc2fdde..eac3368075 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -46,6 +46,7 @@ import type { SingleSelectField, StandardField, ValidatedField, + AgreementField, } from './davinci.types.js'; /** @@ -717,7 +718,7 @@ export function returnObjectValueCollector( * @returns {NoValueCollector} The constructed NoValueCollector object. */ export function returnNoValueCollector< - Field extends ReadOnlyField | QrCodeField, + Field extends ReadOnlyField | QrCodeField | AgreementField, CollectorType extends NoValueCollectorTypes = 'NoValueCollector', >(field: Field, idx: number, collectorType: CollectorType) { let error = ''; @@ -728,6 +729,20 @@ export function returnNoValueCollector< error = `${error}Type is not found in the field object. `; } + let output = {}; + + if (collectorType === 'AgreementCollector' && field.type === 'AGREEMENT') { + output = { + titleEnabled: field.titleEnabled, + title: field.title, + agreement: { + id: field.agreement?.id ?? '', + useDynamicAgreement: field.agreement?.useDynamicAgreement ?? false, + }, + enabled: field.enabled ?? false, + }; + } + return { category: 'NoValueCollector', error: error || null, @@ -738,6 +753,7 @@ export function returnNoValueCollector< key: `${field.key || field.type}-${idx}`, label: field.content, type: field.type, + ...output, }, } as InferNoValueCollectorType; } @@ -771,6 +787,16 @@ export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCo }; } +/** + * @function returnAgreementCollector - Creates an AgreementCollector object based on the provided field and index. + * @param {AgreementField} field - The field object containing key, label, type, and agreement details. + * @param {number} idx - The index to be used in the id of the AgreementCollector. + * @returns {AgreementCollector} The constructed AgreementCollector object. + */ +export function returnAgreementCollector(field: AgreementField, idx: number) { + return returnNoValueCollector(field, idx, 'AgreementCollector'); +} + /** * @function returnValidator - Creates a validator function based on the provided collector * @param {ValidatedTextCollector | ObjectValueCollectors | MultiValueCollectors | AutoCollectors} collector - The collector to which the value will be validated diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 2f97c3c63b..976a28593f 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -81,6 +81,19 @@ export type QrCodeField = { fallbackText?: string; }; +export type AgreementField = { + type: 'AGREEMENT'; + key: string; + content: string; + titleEnabled: boolean; + title: string; + agreement: { + id: string; + useDynamicAgreement: boolean; + }; + enabled: boolean; +}; + export type RedirectField = { type: 'SOCIAL_LOGIN_BUTTON'; key: string; @@ -238,7 +251,7 @@ export type ComplexValueFields = | FidoAuthenticationField | PollingField; export type MultiValueFields = MultiSelectField; -export type ReadOnlyFields = ReadOnlyField | QrCodeField; +export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField; export type RedirectFields = RedirectField; export type SingleValueFields = StandardField | ValidatedField | SingleSelectField | ProtectField; diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index b30d7d12b3..723afbeaff 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -17,6 +17,7 @@ import type { PollingCollector, ProtectCollector, QrCodeCollector, + AgreementCollector, SubmitCollector, TextCollector, } from './collector.types.js'; @@ -450,6 +451,50 @@ describe('The node collector reducer', () => { } satisfies QrCodeCollector, ]); }); + + it('should handle AGREEMENT field type', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'AGREEMENT', + key: 'agreement-field', + content: 'Please accept the terms and conditions', + titleEnabled: true, + title: 'Terms and Conditions', + agreement: { + id: 'agreement-123', + useDynamicAgreement: false, + }, + enabled: true, + }, + ], + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result).toEqual([ + { + category: 'NoValueCollector', + error: null, + type: 'AgreementCollector', + id: 'agreement-field-0', + name: 'agreement-field-0', + output: { + key: 'agreement-field-0', + label: 'Please accept the terms and conditions', + type: 'AGREEMENT', + titleEnabled: true, + title: 'Terms and Conditions', + agreement: { + id: 'agreement-123', + useDynamicAgreement: false, + }, + enabled: true, + }, + } satisfies AgreementCollector, + ]); + }); }); describe('The node collector reducer with MultiValueCollector', () => { diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index e97a0af42e..9162351c8b 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -30,6 +30,7 @@ import { returnFidoRegistrationCollector, returnFidoAuthenticationCollector, returnQrCodeCollector, + returnAgreementCollector, } from './collector.utils.js'; import type { DaVinciField, UnknownField } from './davinci.types.js'; import type { @@ -57,6 +58,7 @@ import type { FidoAuthenticationInputValue, FidoRegistrationInputValue, QrCodeCollector, + AgreementCollector, } from './collector.types.js'; /** @@ -105,6 +107,7 @@ const initialCollectorValues: ( | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector + | AgreementCollector )[] = []; /** @@ -135,6 +138,10 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build return returnQrCodeCollector(field, idx); } + if (field.type === 'AGREEMENT') { + return returnAgreementCollector(field, idx); + } + // *Some* collectors may have default or existing data to display const data = action.payload.formData && diff --git a/packages/davinci-client/src/lib/node.types.test-d.ts b/packages/davinci-client/src/lib/node.types.test-d.ts index d2733abc0e..4fbd40911f 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -37,6 +37,7 @@ import { FidoRegistrationCollector, FidoAuthenticationCollector, QrCodeCollector, + AgreementCollector, } from './collector.types.js'; // ErrorDetail and Links are used as part of the DaVinciError and server._links types respectively @@ -240,6 +241,7 @@ describe('Node Types', () => { | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector + | AgreementCollector | UnknownCollector >(); diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index ecc01c5616..a3572fba38 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -27,6 +27,7 @@ import type { FidoRegistrationCollector, FidoAuthenticationCollector, QrCodeCollector, + AgreementCollector, } from './collector.types.js'; import type { Links } from './davinci.types.js'; @@ -50,6 +51,7 @@ export type Collectors = | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector + | AgreementCollector | UnknownCollector; export interface CollectorErrors {