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
5 changes: 5 additions & 0 deletions .changeset/silent-ideas-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@forgerock/davinci-client': minor
---

Support form agreements with AgreementCollector
30 changes: 30 additions & 0 deletions e2e/davinci-app/components/agreement.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 3 additions & 0 deletions e2e/davinci-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions packages/davinci-client/src/lib/collector.types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import type {
InferSingleValueCollectorType,
InferMultiValueCollectorType,
InferActionCollectorType,
InferNoValueCollectorType,
ReadOnlyCollector,
QrCodeCollector,
AgreementCollector,
} from './collector.types.js';

describe('Collector Types', () => {
Expand Down Expand Up @@ -355,4 +359,65 @@ describe('Collector Types', () => {
expectTypeOf(tCollector).toMatchTypeOf<FlowCollector>();
});
});

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<ReadOnlyCollector>();
});

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<QrCodeCollector>();
});

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<AgreementCollector>();
});
});
});
32 changes: 29 additions & 3 deletions packages/davinci-client/src/lib/collector.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends NoValueCollectorTypes> {
category: 'NoValueCollector';
Expand Down Expand Up @@ -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.
Expand All @@ -523,19 +542,26 @@ export type InferNoValueCollectorType<T extends NoValueCollectorTypes> =
? 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<T extends NoValueCollectorTypes> = NoValueCollectorBase<T>;

export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>;

export type QrCodeCollector = QrCodeCollectorBase;

/** *********************************************************************
* UNKNOWN COLLECTOR
*/

export type UnknownCollector = {
category: 'UnknownCollector';
error: string | null;
Expand Down
45 changes: 45 additions & 0 deletions packages/davinci-client/src/lib/collector.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
returnSingleValueAutoCollector,
returnObjectValueAutoCollector,
returnQrCodeCollector,
returnAgreementCollector,
} from './collector.utils.js';
import type {
DaVinciField,
Expand All @@ -37,6 +38,7 @@ import type {
ReadOnlyField,
RedirectField,
StandardField,
AgreementField,
} from './davinci.types.js';
import type {
MultiSelectCollector,
Expand Down Expand Up @@ -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 = {
Expand Down
28 changes: 27 additions & 1 deletion packages/davinci-client/src/lib/collector.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
SingleSelectField,
StandardField,
ValidatedField,
AgreementField,
} from './davinci.types.js';

/**
Expand Down Expand Up @@ -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,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we update this extends to be on the ReadOnlyFields type

export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField;

CollectorType extends NoValueCollectorTypes = 'NoValueCollector',
>(field: Field, idx: number, collectorType: CollectorType) {
let error = '';
Expand All @@ -728,6 +729,20 @@ export function returnNoValueCollector<
error = `${error}Type is not found in the field object. `;
}

let output = {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of reassigning the output property we should just create it inline like we do elsewhere in the file. Looks cleaner.


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,
Expand All @@ -738,6 +753,7 @@ export function returnNoValueCollector<
key: `${field.key || field.type}-${idx}`,
label: field.content,
type: field.type,
...output,
},
} as InferNoValueCollectorType<CollectorType>;
}
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion packages/davinci-client/src/lib/davinci.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading