From 613e21a0a0ffea28f116201ee7d42192860a38a2 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Mon, 13 Apr 2026 13:10:40 -0600 Subject: [PATCH 1/4] feat(davinci-client): add RichContent types to ReadOnlyField Support RichContent link types by creating a NoValueCollector for it --- .changeset/rich-content-links.md | 13 + e2e/davinci-app/components/label.ts | 37 +- .../src/lib/collector.richcontent.test-d.ts | 108 ++++++ .../src/lib/collector.types.test-d.ts | 2 + .../davinci-client/src/lib/collector.types.ts | 42 ++- .../src/lib/collector.utils.test.ts | 351 +++++++++++++++++- .../davinci-client/src/lib/collector.utils.ts | 107 +++++- .../davinci-client/src/lib/davinci.types.ts | 13 + .../lib/mock-data/mock-form-fields.data.ts | 16 + 9 files changed, 677 insertions(+), 12 deletions(-) create mode 100644 .changeset/rich-content-links.md create mode 100644 packages/davinci-client/src/lib/collector.richcontent.test-d.ts diff --git a/.changeset/rich-content-links.md b/.changeset/rich-content-links.md new file mode 100644 index 0000000000..4a0f40881c --- /dev/null +++ b/.changeset/rich-content-links.md @@ -0,0 +1,13 @@ +--- +'@forgerock/davinci-client': minor +--- + +**Breaking change**: `ReadOnlyCollector.output.content` now returns a plain `string` (the label text) instead of `ContentPart[]`. + +A new `ReadOnlyCollector.output.richContent` property is always present and contains the structured link data when a LABEL field includes `richContent`. Its shape is `CollectorRichContent` — a template string with `{{key}}` placeholders (`content`) and a validated `replacements` array (`ValidatedReplacement[]`). When no `richContent` is present, `replacements` is an empty array. + +**Removed type exports**: `ContentPart`, `TextContentPart`, `LinkContentPart` + +**New type exports**: `RichContentLink`, `ValidatedReplacement`, `CollectorRichContent` + +Includes href protocol validation that rejects unsafe URI schemes (e.g. `javascript:`, `data:`). diff --git a/e2e/davinci-app/components/label.ts b/e2e/davinci-app/components/label.ts index 29fc355fe7..84a9c41eff 100644 --- a/e2e/davinci-app/components/label.ts +++ b/e2e/davinci-app/components/label.ts @@ -7,9 +7,42 @@ import type { ReadOnlyCollector } from '@forgerock/davinci-client/types'; export default function (formEl: HTMLFormElement, collector: ReadOnlyCollector) { - // create paragraph element with text of "Loading ... " const p = document.createElement('p'); + const { richContent } = collector.output; + + if (richContent.replacements.length === 0) { + p.innerText = collector.output.content; + formEl?.appendChild(p); + return; + } + + // 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])); + + 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); + } + } + } - p.innerText = collector.output.label; formEl?.appendChild(p); } diff --git a/packages/davinci-client/src/lib/collector.richcontent.test-d.ts b/packages/davinci-client/src/lib/collector.richcontent.test-d.ts new file mode 100644 index 0000000000..6142ef2324 --- /dev/null +++ b/packages/davinci-client/src/lib/collector.richcontent.test-d.ts @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 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 { describe, expectTypeOf, it } from 'vitest'; +import type { + ReadOnlyCollectorBase, + ReadOnlyCollector, + RichContentLink, + ValidatedReplacement, + CollectorRichContent, + ValidateReplacementsResult, + NoValueCollector, +} from './collector.types.js'; + +describe('Rich Content Types', () => { + describe('RichContentLink', () => { + it('should require key, type, value, and href', () => { + expectTypeOf().toHaveProperty('key').toBeString(); + expectTypeOf().toHaveProperty('type').toEqualTypeOf<'link'>(); + expectTypeOf().toHaveProperty('value').toBeString(); + expectTypeOf().toHaveProperty('href').toBeString(); + }); + + it('should have optional target constrained to _self or _blank', () => { + expectTypeOf() + .toHaveProperty('target') + .toEqualTypeOf<'_self' | '_blank' | undefined>(); + }); + }); + + describe('ValidatedReplacement', () => { + it('should be assignable from RichContentLink', () => { + expectTypeOf().toMatchTypeOf(); + }); + + it('should be assignable to RichContentLink', () => { + expectTypeOf().toMatchTypeOf(); + }); + }); + + describe('CollectorRichContent', () => { + it('should have required content string and replacements array', () => { + expectTypeOf().toHaveProperty('content').toBeString(); + expectTypeOf() + .toHaveProperty('replacements') + .toEqualTypeOf(); + }); + }); + + describe('ValidateReplacementsResult', () => { + it('should narrow to replacements on ok: true', () => { + const result = {} as ValidateReplacementsResult; + if (result.ok) { + expectTypeOf(result.replacements).toEqualTypeOf(); + } + }); + + it('should narrow to error on ok: false', () => { + const result = {} as ValidateReplacementsResult; + if (!result.ok) { + expectTypeOf(result.error).toBeString(); + } + }); + }); + + describe('ReadOnlyCollectorBase', () => { + it('should have content as string, not array', () => { + expectTypeOf().toBeString(); + }); + + it('should have required richContent with CollectorRichContent shape', () => { + expectTypeOf< + ReadOnlyCollectorBase['output']['richContent'] + >().toEqualTypeOf(); + }); + + it('should have standard collector fields', () => { + expectTypeOf() + .toHaveProperty('category') + .toEqualTypeOf<'NoValueCollector'>(); + expectTypeOf() + .toHaveProperty('type') + .toEqualTypeOf<'ReadOnlyCollector'>(); + expectTypeOf().toHaveProperty('error').toEqualTypeOf(); + }); + }); + + describe('NoValueCollector', () => { + it('should resolve to ReadOnlyCollectorBase', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should have content and richContent on output', () => { + type Resolved = NoValueCollector<'ReadOnlyCollector'>; + expectTypeOf().toBeString(); + expectTypeOf().toEqualTypeOf(); + }); + }); + + describe('ReadOnlyCollector alias', () => { + it('should equal ReadOnlyCollectorBase', () => { + expectTypeOf().toEqualTypeOf(); + }); + }); +}); 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 5fe63303ad..2be58e12cf 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -372,6 +372,8 @@ describe('Collector Types', () => { key: 'read-only', label: 'Read Only Field', type: 'READ_ONLY', + content: '', + richContent: { content: '', replacements: [] }, }, }; diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index d99ebacfed..0be326798a 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -501,6 +501,25 @@ export interface NoValueCollectorBase { }; } +export interface RichContentLink { + key: string; + type: 'link'; + value: string; + href: string; + target?: '_self' | '_blank'; +} + +export type ValidatedReplacement = RichContentLink; + +export interface CollectorRichContent { + content: string; + replacements: ValidatedReplacement[]; +} + +export type ValidateReplacementsResult = + | { ok: true; replacements: ValidatedReplacement[] } + | { ok: false; error: string }; + export interface QrCodeCollectorBase { category: 'NoValueCollector'; error: string | null; @@ -515,6 +534,21 @@ export interface QrCodeCollectorBase { }; } +export interface ReadOnlyCollectorBase { + category: 'NoValueCollector'; + error: string | null; + type: 'ReadOnlyCollector'; + id: string; + name: string; + output: { + key: string; + label: string; + type: string; + content: string; + richContent: CollectorRichContent; + }; +} + export interface AgreementCollector extends NoValueCollectorBase<'AgreementCollector'> { output: { key: string; @@ -539,7 +573,7 @@ export interface AgreementCollector extends NoValueCollectorBase<'AgreementColle */ export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' - ? NoValueCollectorBase<'ReadOnlyCollector'> + ? ReadOnlyCollectorBase : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' @@ -548,13 +582,13 @@ export type InferNoValueCollectorType = export type NoValueCollectors = | NoValueCollectorBase<'NoValueCollector'> - | NoValueCollectorBase<'ReadOnlyCollector'> + | ReadOnlyCollectorBase | QrCodeCollectorBase | AgreementCollector; -export type NoValueCollector = NoValueCollectorBase; +export type NoValueCollector = InferNoValueCollectorType; -export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; +export type ReadOnlyCollector = ReadOnlyCollectorBase; export type QrCodeCollector = QrCodeCollectorBase; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index c9d1381857..999a27f702 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -24,6 +24,7 @@ import { returnObjectValueAutoCollector, returnQrCodeCollector, returnAgreementCollector, + validateReplacements, } from './collector.utils.js'; import type { DaVinciField, @@ -37,6 +38,7 @@ import type { PollingField, ReadOnlyField, RedirectField, + RichContentReplacement, StandardField, AgreementField, } from './davinci.types.js'; @@ -796,7 +798,7 @@ describe('No Value Collectors', () => { }); describe('returnReadOnlyCollector', () => { - it('should return a valid ReadOnlyCollector with value in output', () => { + it('should return a ReadOnlyCollector with plain content and empty richContent when no richContent on field', () => { const result = returnReadOnlyCollector(mockField, 0); expect(result).toEqual({ category: 'NoValueCollector', @@ -808,9 +810,130 @@ describe('No Value Collectors', () => { key: 'LABEL-0', label: mockField.content, type: mockField.type, + content: mockField.content, + richContent: { + content: mockField.content, + replacements: [], + }, + }, + }); + }); + + it('should pass through richContent template and validated replacements', () => { + const field: ReadOnlyField = { + type: 'LABEL', + content: 'I agree to the terms and conditions', + richContent: { + content: 'I agree to the {{link}}', + replacements: { + link: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + }, + }, + key: 'terms', + }; + + const result = returnReadOnlyCollector(field, 0); + + expect(result).toEqual({ + category: 'NoValueCollector', + error: null, + type: 'ReadOnlyCollector', + id: 'terms-0', + name: 'terms-0', + output: { + key: 'terms-0', + label: 'I agree to the terms and conditions', + type: 'LABEL', + content: 'I agree to the terms and conditions', + richContent: { + content: 'I agree to the {{link}}', + replacements: [ + { + key: 'link', + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + ], + }, + }, + }); + }); + + it('should set error and empty replacements when richContent has unsafe href', () => { + const field: ReadOnlyField = { + type: 'LABEL', + content: 'Click the link', + richContent: { + content: 'Click {{bad}}', + replacements: { + bad: { + type: 'link', + value: 'here', + href: 'javascript:alert(1)', + }, + }, + }, + }; + + const result = returnReadOnlyCollector(field, 0); + + expect(result.error).toBe('Unsafe href protocol for key: bad'); + expect(result.output.content).toBe('Click the link'); + expect(result.output.richContent).toEqual({ + content: 'Click {{bad}}', + replacements: [], + }); + }); + + it('should validate template keys exist in replacements', () => { + const field: ReadOnlyField = { + type: 'LABEL', + content: 'Fallback text', + richContent: { + content: 'Click {{broken}}', + replacements: {}, }, + }; + + const result = returnReadOnlyCollector(field, 0); + + expect(result.error).toBe('Missing replacement for key: {{broken}}'); + expect(result.output.content).toBe('Fallback text'); + expect(result.output.richContent).toEqual({ + content: 'Click {{broken}}', + replacements: [], }); }); + + it('should report all missing keys when template has partial replacement coverage', () => { + const field: ReadOnlyField = { + type: 'LABEL', + content: 'Read our terms and policy', + richContent: { + content: 'Read our {{link1}} and {{link2}}', + replacements: { + link1: { + type: 'link', + value: 'terms', + href: 'https://example.com/terms', + }, + }, + }, + }; + + const result = returnReadOnlyCollector(field, 0); + + expect(result.error).toBe('Missing replacement for key: {{link2}}'); + expect(result.output.content).toBe('Read our terms and policy'); + expect(result.output.richContent.replacements).toEqual([]); + }); }); }); @@ -1181,3 +1304,229 @@ describe('Return collector validator', () => { ); }); }); + +describe('validateReplacements', () => { + it('should validate a single link replacement', () => { + const replacements: Record = { + link1: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + }; + + const result = validateReplacements(replacements); + + expect(result).toEqual({ + ok: true, + replacements: [ + { + key: 'link1', + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + ], + }); + }); + + it('should validate multiple link replacements', () => { + const replacements: Record = { + link1: { + type: 'link', + value: 'terms', + href: 'https://example.com', + target: '_blank', + }, + link2: { + type: 'link', + value: 'policy', + href: 'https://xyz.com', + target: '_self', + }, + }; + + const result = validateReplacements(replacements); + + expect(result).toEqual({ + ok: true, + replacements: [ + { + key: 'link1', + type: 'link', + value: 'terms', + href: 'https://example.com', + target: '_blank', + }, + { key: 'link2', type: 'link', value: 'policy', href: 'https://xyz.com', target: '_self' }, + ], + }); + }); + + it('should omit target when not provided', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'https://example.com', + }, + }; + + const result = validateReplacements(replacements); + + expect(result).toEqual({ + ok: true, + replacements: [{ key: 'link', type: 'link', value: 'here', href: 'https://example.com' }], + }); + }); + + it('should return empty array for empty replacements', () => { + const result = validateReplacements({}); + + expect(result).toEqual({ ok: true, replacements: [] }); + }); + + it('should return error for javascript: URI scheme', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'javascript:alert(1)', + }, + }; + + const result = validateReplacements(replacements); + + expect(result).toEqual({ ok: false, error: 'Unsafe href protocol for key: link' }); + }); + + it('should return error for data: URI scheme', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'data:text/html,', + }, + }; + + const result = validateReplacements(replacements); + + expect(result).toEqual({ ok: false, error: 'Unsafe href protocol for key: link' }); + }); + + it('should return error for invalid href', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'not a url', + }, + }; + + const result = validateReplacements(replacements); + + expect(result).toEqual({ ok: false, error: 'Invalid href for key: link' }); + }); + + it('should allow https: href', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'https://example.com/terms', + }, + }; + + const result = validateReplacements(replacements); + + expect(result).toEqual({ + ok: true, + replacements: [ + { key: 'link', type: 'link', value: 'here', href: 'https://example.com/terms' }, + ], + }); + }); + + it('should allow http: href', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'http://example.com/terms', + }, + }; + + const result = validateReplacements(replacements); + + expect(result).toEqual({ + ok: true, + replacements: [ + { key: 'link', type: 'link', value: 'here', href: 'http://example.com/terms' }, + ], + }); + }); +}); + +describe('Terms and Conditions Integration', () => { + it('should handle a form with a checkbox and a label with a T&C link', () => { + const labelField: ReadOnlyField = { + type: 'LABEL', + content: 'I agree to the terms and conditions', + richContent: { + content: 'I agree to the {{link}}', + replacements: { + link: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com/terms', + target: '_blank', + }, + }, + }, + key: 'terms-label', + }; + + const checkboxField = { + type: 'CHECKBOX' as const, + key: 'agree-checkbox', + label: 'Agreement', + required: true, + options: [{ label: 'I agree', value: 'agree' }], + inputType: 'MULTI_SELECT' as const, + }; + + const labelCollector = returnReadOnlyCollector(labelField, 0); + const checkboxCollector = returnMultiSelectCollector(checkboxField, 1, []); + + // Verify label collector has pass-through richContent + expect(labelCollector.type).toBe('ReadOnlyCollector'); + expect(labelCollector.category).toBe('NoValueCollector'); + expect(labelCollector.error).toBeNull(); + expect(labelCollector.output.label).toBe('I agree to the terms and conditions'); + expect(labelCollector.output.content).toBe('I agree to the terms and conditions'); + expect(labelCollector.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', + }, + ], + }); + + // Verify checkbox collector works alongside + expect(checkboxCollector.type).toBe('MultiSelectCollector'); + expect(checkboxCollector.category).toBe('MultiValueCollector'); + expect(checkboxCollector.error).toBeNull(); + expect(checkboxCollector.output.options).toEqual([{ label: 'I agree', value: 'agree' }]); + expect(checkboxCollector.input.value).toEqual([]); + expect(checkboxCollector.input.validation).toEqual([ + { type: 'required', message: 'Value cannot be empty', rule: true }, + ]); + }); +}); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 6233c3202d..ebdfc70088 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -31,6 +31,9 @@ import type { ObjectValueAutoCollectorTypes, QrCodeCollectorBase, AgreementCollector, + ValidatedReplacement, + ValidateReplacementsResult, + ReadOnlyCollectorBase, } from './collector.types.js'; import type { DeviceAuthenticationField, @@ -44,6 +47,7 @@ import type { PollingField, ReadOnlyField, RedirectField, + RichContentReplacement, SingleSelectField, StandardField, ValidatedField, @@ -712,6 +716,42 @@ export function returnObjectValueCollector( return returnObjectCollector(field, idx, 'PhoneNumberCollector', prefillData); } +/** + * @function validateReplacements - Validates replacement hrefs and converts the + * Record from the API response into a ValidatedReplacement[]. + * Returns a discriminated result — never throws. + * + * @param {Record} replacements - The replacements object from the API. + * @returns {ValidateReplacementsResult} Success with validated array, or failure with error message. + */ +export function validateReplacements( + replacements: Record, +): ValidateReplacementsResult { + const validated: ValidatedReplacement[] = []; + + for (const [key, replacement] of Object.entries(replacements)) { + let href: URL; + try { + href = new URL(replacement.href); + } catch { + return { ok: false, error: `Invalid href for key: ${key}` }; + } + if (!['https:', 'http:'].includes(href.protocol)) { + return { ok: false, error: `Unsafe href protocol for key: ${key}` }; + } + + validated.push({ + key, + type: replacement.type, + value: replacement.value, + href: replacement.href, + ...(replacement.target && { target: replacement.target }), + }); + } + + return { ok: true, replacements: validated }; +} + /** * @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. @@ -746,13 +786,70 @@ export function returnNoValueCollector< } /** - * @function returnReadOnlyCollector - Creates a ReadOnlyCollector object based on the provided field and index. - * @param {DaVinciField} field - The field object containing key, label, type, and links. + * @function returnReadOnlyCollector - Creates a ReadOnlyCollector with pass-through rich content. + * When richContent is present, validates replacements and passes through the template. + * When absent, richContent echoes the plain content with empty replacements. + * + * @param {ReadOnlyField} field - The LABEL field from the API response. * @param {number} idx - The index to be used in the id of the ReadOnlyCollector. - * @returns {ReadOnlyCollector} The constructed ReadOnlyCollector object. + * @returns {ReadOnlyCollectorBase} The constructed ReadOnlyCollector. */ -export function returnReadOnlyCollector(field: ReadOnlyField, idx: number) { - return returnNoValueCollector(field, idx, 'ReadOnlyCollector'); +export function returnReadOnlyCollector(field: ReadOnlyField, idx: number): ReadOnlyCollectorBase { + const fieldErrors = [ + ...(!('content' in field) ? ['Content is not found in the field object.'] : []), + ...(!('type' in field) ? ['Type is not found in the field object.'] : []), + ]; + + const id = `${field.key || field.type}-${idx}`; + + if (!field.richContent) { + const errors = fieldErrors; + return { + category: 'NoValueCollector', + error: errors.length > 0 ? errors.join(' ') : null, + type: 'ReadOnlyCollector', + id, + name: id, + output: { + key: id, + label: field.content, + type: field.type, + content: field.content, + richContent: { content: field.content, replacements: [] }, + }, + }; + } + + // Validate that all {{key}} references in the template have corresponding replacements + const templateKeys = [...field.richContent.content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); + const apiReplacements = field.richContent.replacements ?? {}; + const missingKeys = templateKeys.filter((k) => !(k in apiReplacements)); + const templateErrors = missingKeys.map((k) => `Missing replacement for key: {{${k}}}`); + + const validationResult = + templateErrors.length === 0 ? validateReplacements(apiReplacements) : null; + + const replacements = validationResult?.ok ? validationResult.replacements : []; + const validationErrors = validationResult && !validationResult.ok ? [validationResult.error] : []; + const errors = [...fieldErrors, ...templateErrors, ...validationErrors]; + + return { + category: 'NoValueCollector', + error: errors.length > 0 ? errors.join(' ') : null, + type: 'ReadOnlyCollector', + id, + name: id, + output: { + key: id, + label: field.content, + type: field.type, + content: field.content, + richContent: { + content: field.richContent.content, + replacements, + }, + }, + }; } /** diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 2b0f21f24b..010980333c 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -68,9 +68,22 @@ export type StandardField = { required?: boolean; }; +export type RichContentReplacement = { + type: 'link'; + value: string; + href: string; + target?: '_self' | '_blank'; +}; + +export type RichContent = { + content: string; + replacements?: Record; +}; + export type ReadOnlyField = { type: 'LABEL'; content: string; + richContent?: RichContent; key?: string; }; diff --git a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts index 4962470b45..2e8f9061ae 100644 --- a/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts +++ b/packages/davinci-client/src/lib/mock-data/mock-form-fields.data.ts @@ -23,6 +23,22 @@ export const obj = { type: 'LABEL', content: 'Welcome to Ping Identity', }, + { + type: 'LABEL', + content: 'I agree to the terms and conditions', + richContent: { + content: 'I agree to the {{link}}', + replacements: { + link: { + type: 'link', + value: 'terms and conditions', + href: 'https://example.com/terms', + target: '_blank', + }, + }, + }, + key: 'terms-label', + }, { type: 'ERROR_DISPLAY', }, From 099321c3de8ca858a380690a5bd6f0e18b023ff0 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 22 Apr 2026 14:10:41 -0600 Subject: [PATCH 2/4] chore: nxignore .opensource dir and preserve richtext newlines - Add .nxignore to exclude vendored .opensource/ clone from Nx project graph (was causing duplicate-project errors vs forgerock-verdaccio). - Render authored line breaks in ReadOnlyCollector rich text via white-space: pre-line on the rendered

. --- .nxignore | 1 + e2e/davinci-app/components/label.ts | 1 + 2 files changed, 2 insertions(+) create mode 100644 .nxignore diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000000..0467d38052 --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +.opensource/ diff --git a/e2e/davinci-app/components/label.ts b/e2e/davinci-app/components/label.ts index 84a9c41eff..4d896f1490 100644 --- a/e2e/davinci-app/components/label.ts +++ b/e2e/davinci-app/components/label.ts @@ -8,6 +8,7 @@ import type { ReadOnlyCollector } from '@forgerock/davinci-client/types'; export default function (formEl: HTMLFormElement, collector: ReadOnlyCollector) { const p = document.createElement('p'); + p.style.whiteSpace = 'pre-line'; const { richContent } = collector.output; if (richContent.replacements.length === 0) { From d8d79979457f5991b83483ca507b48d1ed074ef2 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Tue, 28 Apr 2026 11:17:41 -0600 Subject: [PATCH 3/4] chore: pr-comments --- .opensource/forgerock-javascript-sdk | 1 + .../api-report/davinci-client.api.md | 93 ++++++--- .../api-report/davinci-client.types.api.md | 93 ++++++--- .../src/lib/collector.richcontent.test-d.ts | 108 ---------- .../src/lib/collector.types.test-d.ts | 63 ++++++ .../davinci-client/src/lib/collector.types.ts | 65 +++--- .../src/lib/collector.utils.test.ts | 193 +++--------------- .../davinci-client/src/lib/collector.utils.ts | 107 +++------- .../davinci-client/src/lib/davinci.types.ts | 16 ++ 9 files changed, 294 insertions(+), 445 deletions(-) create mode 160000 .opensource/forgerock-javascript-sdk delete mode 100644 packages/davinci-client/src/lib/collector.richcontent.test-d.ts diff --git a/.opensource/forgerock-javascript-sdk b/.opensource/forgerock-javascript-sdk new file mode 160000 index 0000000000..1e3f0d7de2 --- /dev/null +++ b/.opensource/forgerock-javascript-sdk @@ -0,0 +1 @@ +Subproject commit 1e3f0d7de2572ae5a0433525c5af65c73c031e67 diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..c555aaa42c 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -177,6 +177,14 @@ export interface CollectorErrors { target: string; } +// @public +export interface CollectorRichContent { + // (undocumented) + content: string; + // (undocumented) + replacements: RichContentLink[]; +} + // @public (undocumented) export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; @@ -267,13 +275,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; poll: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +293,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +305,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +314,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +329,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -1029,7 +1037,7 @@ export type InferAutoCollectorType = T extends 'Pr export type InferMultiValueCollectorType = T extends 'MultiSelectCollector' ? MultiValueCollectorWithValue<'MultiSelectCollector'> : MultiValueCollectorWithValue<'MultiValueCollector'> | MultiValueCollectorNoValue<'MultiValueCollector'>; // @public -export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; +export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? ReadOnlyCollector : T extends 'QrCodeCollector' ? QrCodeCollector : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; // @public export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; @@ -1170,15 +1178,15 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) export type NodeStates = StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode; // @public (undocumented) -export type NoValueCollector = NoValueCollectorBase; +export type NoValueCollector = InferNoValueCollectorType; // @public (undocumented) export interface NoValueCollectorBase { @@ -1201,7 +1209,7 @@ export interface NoValueCollectorBase { } // @public (undocumented) -export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | NoValueCollectorBase<'ReadOnlyCollector'> | QrCodeCollectorBase | AgreementCollector; +export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | QrCodeCollector | AgreementCollector; // @public export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; @@ -1415,28 +1423,12 @@ export interface ProtectOutputValue { universalDeviceIdentification: boolean; } -// @public (undocumented) -export type QrCodeCollector = QrCodeCollectorBase; - -// @public (undocumented) -export interface QrCodeCollectorBase { - // (undocumented) - category: 'NoValueCollector'; - // (undocumented) - error: string | null; - // (undocumented) - id: string; - // (undocumented) - name: string; +// @public +export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> { // (undocumented) - output: { - key: string; - label: string; - type: string; + output: NoValueCollectorBase<'QrCodeCollector'>['output'] & { src: string; }; - // (undocumented) - type: 'QrCodeCollector'; } // @public (undocumented) @@ -1447,13 +1439,20 @@ export type QrCodeField = { fallbackText?: string; }; -// @public (undocumented) -export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; +// @public +export interface ReadOnlyCollector extends NoValueCollectorBase<'ReadOnlyCollector'> { + // (undocumented) + output: NoValueCollectorBase<'ReadOnlyCollector'>['output'] & { + content: string; + richContent: CollectorRichContent; + }; +} -// @public (undocumented) +// @public export type ReadOnlyField = { type: 'LABEL'; content: string; + richContent?: RichContent; key?: string; }; @@ -1473,6 +1472,34 @@ export type RedirectFields = RedirectField; export { RequestMiddleware } +// @public +export type RichContent = { + content: string; + replacements?: Record; +}; + +// @public +export interface RichContentLink { + // (undocumented) + href: string; + // (undocumented) + key: string; + // (undocumented) + target?: '_self' | '_blank'; + // (undocumented) + type: 'link'; + // (undocumented) + value: string; +} + +// @public +export type RichContentReplacement = { + type: 'link'; + value: string; + href: string; + target?: '_self' | '_blank'; +}; + // @public (undocumented) export interface SelectorOption { // (undocumented) 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 2321431a0a..1492074108 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -177,6 +177,14 @@ export interface CollectorErrors { target: string; } +// @public +export interface CollectorRichContent { + // (undocumented) + content: string; + // (undocumented) + replacements: RichContentLink[]; +} + // @public (undocumented) export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ReadOnlyCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; @@ -267,13 +275,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; poll: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +293,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +305,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +314,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +329,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -1026,7 +1034,7 @@ export type InferAutoCollectorType = T extends 'Pr export type InferMultiValueCollectorType = T extends 'MultiSelectCollector' ? MultiValueCollectorWithValue<'MultiSelectCollector'> : MultiValueCollectorWithValue<'MultiValueCollector'> | MultiValueCollectorNoValue<'MultiValueCollector'>; // @public -export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? NoValueCollectorBase<'ReadOnlyCollector'> : T extends 'QrCodeCollector' ? QrCodeCollectorBase : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; +export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' ? ReadOnlyCollector : T extends 'QrCodeCollector' ? QrCodeCollector : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; // @public export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; @@ -1167,15 +1175,15 @@ value: Record; }, string>; // @public -export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { - getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollectorBase | AgreementCollector | ReadOnlyCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; +export const nodeCollectorReducer: Reducer<(TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]; }; // @public (undocumented) export type NodeStates = StartNode | ContinueNode | ErrorNode | SuccessNode | FailureNode; // @public (undocumented) -export type NoValueCollector = NoValueCollectorBase; +export type NoValueCollector = InferNoValueCollectorType; // @public (undocumented) export interface NoValueCollectorBase { @@ -1198,7 +1206,7 @@ export interface NoValueCollectorBase { } // @public (undocumented) -export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | NoValueCollectorBase<'ReadOnlyCollector'> | QrCodeCollectorBase | AgreementCollector; +export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | QrCodeCollector | AgreementCollector; // @public export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; @@ -1412,28 +1420,12 @@ export interface ProtectOutputValue { universalDeviceIdentification: boolean; } -// @public (undocumented) -export type QrCodeCollector = QrCodeCollectorBase; - -// @public (undocumented) -export interface QrCodeCollectorBase { - // (undocumented) - category: 'NoValueCollector'; - // (undocumented) - error: string | null; - // (undocumented) - id: string; - // (undocumented) - name: string; +// @public +export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> { // (undocumented) - output: { - key: string; - label: string; - type: string; + output: NoValueCollectorBase<'QrCodeCollector'>['output'] & { src: string; }; - // (undocumented) - type: 'QrCodeCollector'; } // @public (undocumented) @@ -1444,13 +1436,20 @@ export type QrCodeField = { fallbackText?: string; }; -// @public (undocumented) -export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; +// @public +export interface ReadOnlyCollector extends NoValueCollectorBase<'ReadOnlyCollector'> { + // (undocumented) + output: NoValueCollectorBase<'ReadOnlyCollector'>['output'] & { + content: string; + richContent: CollectorRichContent; + }; +} -// @public (undocumented) +// @public export type ReadOnlyField = { type: 'LABEL'; content: string; + richContent?: RichContent; key?: string; }; @@ -1470,6 +1469,34 @@ export type RedirectFields = RedirectField; export { RequestMiddleware } +// @public +export type RichContent = { + content: string; + replacements?: Record; +}; + +// @public +export interface RichContentLink { + // (undocumented) + href: string; + // (undocumented) + key: string; + // (undocumented) + target?: '_self' | '_blank'; + // (undocumented) + type: 'link'; + // (undocumented) + value: string; +} + +// @public +export type RichContentReplacement = { + type: 'link'; + value: string; + href: string; + target?: '_self' | '_blank'; +}; + // @public (undocumented) export interface SelectorOption { // (undocumented) diff --git a/packages/davinci-client/src/lib/collector.richcontent.test-d.ts b/packages/davinci-client/src/lib/collector.richcontent.test-d.ts deleted file mode 100644 index 6142ef2324..0000000000 --- a/packages/davinci-client/src/lib/collector.richcontent.test-d.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (c) 2025 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 { describe, expectTypeOf, it } from 'vitest'; -import type { - ReadOnlyCollectorBase, - ReadOnlyCollector, - RichContentLink, - ValidatedReplacement, - CollectorRichContent, - ValidateReplacementsResult, - NoValueCollector, -} from './collector.types.js'; - -describe('Rich Content Types', () => { - describe('RichContentLink', () => { - it('should require key, type, value, and href', () => { - expectTypeOf().toHaveProperty('key').toBeString(); - expectTypeOf().toHaveProperty('type').toEqualTypeOf<'link'>(); - expectTypeOf().toHaveProperty('value').toBeString(); - expectTypeOf().toHaveProperty('href').toBeString(); - }); - - it('should have optional target constrained to _self or _blank', () => { - expectTypeOf() - .toHaveProperty('target') - .toEqualTypeOf<'_self' | '_blank' | undefined>(); - }); - }); - - describe('ValidatedReplacement', () => { - it('should be assignable from RichContentLink', () => { - expectTypeOf().toMatchTypeOf(); - }); - - it('should be assignable to RichContentLink', () => { - expectTypeOf().toMatchTypeOf(); - }); - }); - - describe('CollectorRichContent', () => { - it('should have required content string and replacements array', () => { - expectTypeOf().toHaveProperty('content').toBeString(); - expectTypeOf() - .toHaveProperty('replacements') - .toEqualTypeOf(); - }); - }); - - describe('ValidateReplacementsResult', () => { - it('should narrow to replacements on ok: true', () => { - const result = {} as ValidateReplacementsResult; - if (result.ok) { - expectTypeOf(result.replacements).toEqualTypeOf(); - } - }); - - it('should narrow to error on ok: false', () => { - const result = {} as ValidateReplacementsResult; - if (!result.ok) { - expectTypeOf(result.error).toBeString(); - } - }); - }); - - describe('ReadOnlyCollectorBase', () => { - it('should have content as string, not array', () => { - expectTypeOf().toBeString(); - }); - - it('should have required richContent with CollectorRichContent shape', () => { - expectTypeOf< - ReadOnlyCollectorBase['output']['richContent'] - >().toEqualTypeOf(); - }); - - it('should have standard collector fields', () => { - expectTypeOf() - .toHaveProperty('category') - .toEqualTypeOf<'NoValueCollector'>(); - expectTypeOf() - .toHaveProperty('type') - .toEqualTypeOf<'ReadOnlyCollector'>(); - expectTypeOf().toHaveProperty('error').toEqualTypeOf(); - }); - }); - - describe('NoValueCollector', () => { - it('should resolve to ReadOnlyCollectorBase', () => { - expectTypeOf>().toEqualTypeOf(); - }); - - it('should have content and richContent on output', () => { - type Resolved = NoValueCollector<'ReadOnlyCollector'>; - expectTypeOf().toBeString(); - expectTypeOf().toEqualTypeOf(); - }); - }); - - describe('ReadOnlyCollector alias', () => { - it('should equal ReadOnlyCollectorBase', () => { - expectTypeOf().toEqualTypeOf(); - }); - }); -}); 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 2be58e12cf..5e9bbe8e31 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -27,6 +27,9 @@ import type { ReadOnlyCollector, QrCodeCollector, AgreementCollector, + RichContentLink, + CollectorRichContent, + NoValueCollector, } from './collector.types.js'; describe('Collector Types', () => { @@ -422,4 +425,64 @@ describe('Collector Types', () => { expectTypeOf(tCollector).toEqualTypeOf(); }); }); + + describe('Rich Content Types', () => { + describe('RichContentLink', () => { + it('should require key, type, value, and href', () => { + expectTypeOf().toHaveProperty('key').toBeString(); + expectTypeOf().toHaveProperty('type').toEqualTypeOf<'link'>(); + expectTypeOf().toHaveProperty('value').toBeString(); + expectTypeOf().toHaveProperty('href').toBeString(); + }); + + it('should have optional target constrained to _self or _blank', () => { + expectTypeOf() + .toHaveProperty('target') + .toEqualTypeOf<'_self' | '_blank' | undefined>(); + }); + }); + + describe('CollectorRichContent', () => { + it('should have required content string and replacements array', () => { + expectTypeOf().toHaveProperty('content').toBeString(); + expectTypeOf() + .toHaveProperty('replacements') + .toEqualTypeOf(); + }); + }); + + describe('ReadOnlyCollector', () => { + it('should have content as string, not array', () => { + expectTypeOf().toBeString(); + }); + + it('should have required richContent with CollectorRichContent shape', () => { + expectTypeOf< + ReadOnlyCollector['output']['richContent'] + >().toEqualTypeOf(); + }); + + it('should have standard collector fields', () => { + expectTypeOf() + .toHaveProperty('category') + .toEqualTypeOf<'NoValueCollector'>(); + expectTypeOf() + .toHaveProperty('type') + .toEqualTypeOf<'ReadOnlyCollector'>(); + expectTypeOf().toHaveProperty('error').toEqualTypeOf(); + }); + }); + + describe("NoValueCollector<'ReadOnlyCollector'>", () => { + it('should resolve to ReadOnlyCollector', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should have content and richContent on output', () => { + type Resolved = NoValueCollector<'ReadOnlyCollector'>; + expectTypeOf().toBeString(); + expectTypeOf().toEqualTypeOf(); + }); + }); + }); }); diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index 0be326798a..d231afac76 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -501,6 +501,12 @@ export interface NoValueCollectorBase { }; } +/** + * @interface RichContentLink - A hyperlink replacement embedded inside a + * `ReadOnlyCollector` template. The `key` matches the `{{key}}` token in the + * template; `href` is passed through from DaVinci unmodified — consumers are + * responsible for sanitizing it before rendering. + */ export interface RichContentLink { key: string; type: 'link'; @@ -509,41 +515,34 @@ export interface RichContentLink { target?: '_self' | '_blank'; } -export type ValidatedReplacement = RichContentLink; - +/** + * @interface CollectorRichContent - The normalized rich-content payload exposed on a + * `ReadOnlyCollector`. `content` holds the raw template (with `{{key}}` tokens), and + * `replacements` is the array of substitution entries (the API's keyed Record flattened + * into an array, with the original key carried on each entry). + */ export interface CollectorRichContent { content: string; - replacements: ValidatedReplacement[]; + replacements: RichContentLink[]; } -export type ValidateReplacementsResult = - | { ok: true; replacements: ValidatedReplacement[] } - | { ok: false; error: string }; - -export interface QrCodeCollectorBase { - category: 'NoValueCollector'; - error: string | null; - type: 'QrCodeCollector'; - id: string; - name: string; - output: { - key: string; - label: string; - type: string; +/** + * @interface QrCodeCollector - Collector for displaying a QR code image. Extends the + * generic `NoValueCollectorBase` with the image `src` on `output`. + */ +export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> { + output: NoValueCollectorBase<'QrCodeCollector'>['output'] & { src: string; }; } -export interface ReadOnlyCollectorBase { - category: 'NoValueCollector'; - error: string | null; - type: 'ReadOnlyCollector'; - id: string; - name: string; - output: { - key: string; - label: string; - type: string; +/** + * @interface ReadOnlyCollector - Display-only collector for LABEL fields. Extends + * `NoValueCollectorBase` with the original plain-text `content` and a structured + * `richContent` payload (template + validated replacements). + */ +export interface ReadOnlyCollector extends NoValueCollectorBase<'ReadOnlyCollector'> { + output: NoValueCollectorBase<'ReadOnlyCollector'>['output'] & { content: string; richContent: CollectorRichContent; }; @@ -573,25 +572,21 @@ export interface AgreementCollector extends NoValueCollectorBase<'AgreementColle */ export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' - ? ReadOnlyCollectorBase + ? ReadOnlyCollector : T extends 'QrCodeCollector' - ? QrCodeCollectorBase + ? QrCodeCollector : T extends 'AgreementCollector' ? AgreementCollector : NoValueCollectorBase<'NoValueCollector'>; export type NoValueCollectors = | NoValueCollectorBase<'NoValueCollector'> - | ReadOnlyCollectorBase - | QrCodeCollectorBase + | ReadOnlyCollector + | QrCodeCollector | AgreementCollector; export type NoValueCollector = InferNoValueCollectorType; -export type ReadOnlyCollector = ReadOnlyCollectorBase; - -export type QrCodeCollector = QrCodeCollectorBase; - /** ********************************************************************* * UNKNOWN COLLECTOR */ diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index 999a27f702..4e033ac479 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -24,7 +24,7 @@ import { returnObjectValueAutoCollector, returnQrCodeCollector, returnAgreementCollector, - validateReplacements, + normalizeReplacements, } from './collector.utils.js'; import type { DaVinciField, @@ -866,7 +866,7 @@ describe('No Value Collectors', () => { }); }); - it('should set error and empty replacements when richContent has unsafe href', () => { + it('should pass through unsafe-looking hrefs unchanged (consumer is responsible for sanitization)', () => { const field: ReadOnlyField = { type: 'LABEL', content: 'Click the link', @@ -884,56 +884,12 @@ describe('No Value Collectors', () => { const result = returnReadOnlyCollector(field, 0); - expect(result.error).toBe('Unsafe href protocol for key: bad'); - expect(result.output.content).toBe('Click the link'); + expect(result.error).toBeNull(); expect(result.output.richContent).toEqual({ content: 'Click {{bad}}', - replacements: [], + replacements: [{ key: 'bad', type: 'link', value: 'here', href: 'javascript:alert(1)' }], }); }); - - it('should validate template keys exist in replacements', () => { - const field: ReadOnlyField = { - type: 'LABEL', - content: 'Fallback text', - richContent: { - content: 'Click {{broken}}', - replacements: {}, - }, - }; - - const result = returnReadOnlyCollector(field, 0); - - expect(result.error).toBe('Missing replacement for key: {{broken}}'); - expect(result.output.content).toBe('Fallback text'); - expect(result.output.richContent).toEqual({ - content: 'Click {{broken}}', - replacements: [], - }); - }); - - it('should report all missing keys when template has partial replacement coverage', () => { - const field: ReadOnlyField = { - type: 'LABEL', - content: 'Read our terms and policy', - richContent: { - content: 'Read our {{link1}} and {{link2}}', - replacements: { - link1: { - type: 'link', - value: 'terms', - href: 'https://example.com/terms', - }, - }, - }, - }; - - const result = returnReadOnlyCollector(field, 0); - - expect(result.error).toBe('Missing replacement for key: {{link2}}'); - expect(result.output.content).toBe('Read our terms and policy'); - expect(result.output.richContent.replacements).toEqual([]); - }); }); }); @@ -1305,8 +1261,8 @@ describe('Return collector validator', () => { }); }); -describe('validateReplacements', () => { - it('should validate a single link replacement', () => { +describe('normalizeReplacements', () => { + it('should flatten a single link replacement', () => { const replacements: Record = { link1: { type: 'link', @@ -1316,23 +1272,18 @@ describe('validateReplacements', () => { }, }; - const result = validateReplacements(replacements); - - expect(result).toEqual({ - ok: true, - replacements: [ - { - key: 'link1', - type: 'link', - value: 'terms and conditions', - href: 'https://example.com', - target: '_blank', - }, - ], - }); + expect(normalizeReplacements(replacements)).toEqual([ + { + key: 'link1', + type: 'link', + value: 'terms and conditions', + href: 'https://example.com', + target: '_blank', + }, + ]); }); - it('should validate multiple link replacements', () => { + it('should flatten multiple link replacements', () => { const replacements: Record = { link1: { type: 'link', @@ -1348,21 +1299,16 @@ describe('validateReplacements', () => { }, }; - const result = validateReplacements(replacements); - - expect(result).toEqual({ - ok: true, - replacements: [ - { - key: 'link1', - type: 'link', - value: 'terms', - href: 'https://example.com', - target: '_blank', - }, - { key: 'link2', type: 'link', value: 'policy', href: 'https://xyz.com', target: '_self' }, - ], - }); + expect(normalizeReplacements(replacements)).toEqual([ + { + key: 'link1', + type: 'link', + value: 'terms', + href: 'https://example.com', + target: '_blank', + }, + { key: 'link2', type: 'link', value: 'policy', href: 'https://xyz.com', target: '_self' }, + ]); }); it('should omit target when not provided', () => { @@ -1374,21 +1320,16 @@ describe('validateReplacements', () => { }, }; - const result = validateReplacements(replacements); - - expect(result).toEqual({ - ok: true, - replacements: [{ key: 'link', type: 'link', value: 'here', href: 'https://example.com' }], - }); + expect(normalizeReplacements(replacements)).toEqual([ + { key: 'link', type: 'link', value: 'here', href: 'https://example.com' }, + ]); }); it('should return empty array for empty replacements', () => { - const result = validateReplacements({}); - - expect(result).toEqual({ ok: true, replacements: [] }); + expect(normalizeReplacements({})).toEqual([]); }); - it('should return error for javascript: URI scheme', () => { + it('should pass non-http(s) hrefs through unchanged', () => { const replacements: Record = { link: { type: 'link', @@ -1397,75 +1338,9 @@ describe('validateReplacements', () => { }, }; - const result = validateReplacements(replacements); - - expect(result).toEqual({ ok: false, error: 'Unsafe href protocol for key: link' }); - }); - - it('should return error for data: URI scheme', () => { - const replacements: Record = { - link: { - type: 'link', - value: 'here', - href: 'data:text/html,', - }, - }; - - const result = validateReplacements(replacements); - - expect(result).toEqual({ ok: false, error: 'Unsafe href protocol for key: link' }); - }); - - it('should return error for invalid href', () => { - const replacements: Record = { - link: { - type: 'link', - value: 'here', - href: 'not a url', - }, - }; - - const result = validateReplacements(replacements); - - expect(result).toEqual({ ok: false, error: 'Invalid href for key: link' }); - }); - - it('should allow https: href', () => { - const replacements: Record = { - link: { - type: 'link', - value: 'here', - href: 'https://example.com/terms', - }, - }; - - const result = validateReplacements(replacements); - - expect(result).toEqual({ - ok: true, - replacements: [ - { key: 'link', type: 'link', value: 'here', href: 'https://example.com/terms' }, - ], - }); - }); - - it('should allow http: href', () => { - const replacements: Record = { - link: { - type: 'link', - value: 'here', - href: 'http://example.com/terms', - }, - }; - - const result = validateReplacements(replacements); - - expect(result).toEqual({ - ok: true, - replacements: [ - { key: 'link', type: 'link', value: 'here', href: 'http://example.com/terms' }, - ], - }); + expect(normalizeReplacements(replacements)).toEqual([ + { key: 'link', type: 'link', value: 'here', href: 'javascript:alert(1)' }, + ]); }); }); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index ebdfc70088..0226731176 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -29,11 +29,10 @@ import type { AutoCollectors, SingleValueAutoCollectorTypes, ObjectValueAutoCollectorTypes, - QrCodeCollectorBase, + QrCodeCollector, + ReadOnlyCollector, + RichContentLink, AgreementCollector, - ValidatedReplacement, - ValidateReplacementsResult, - ReadOnlyCollectorBase, } from './collector.types.js'; import type { DeviceAuthenticationField, @@ -717,39 +716,24 @@ export function returnObjectValueCollector( } /** - * @function validateReplacements - Validates replacement hrefs and converts the - * Record from the API response into a ValidatedReplacement[]. - * Returns a discriminated result — never throws. + * @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 object from the API. - * @returns {ValidateReplacementsResult} Success with validated array, or failure with error message. + * @param {Record} replacements - The replacements map from the API. + * @returns {RichContentLink[]} The flattened array of replacement entries. */ -export function validateReplacements( +export function normalizeReplacements( replacements: Record, -): ValidateReplacementsResult { - const validated: ValidatedReplacement[] = []; - - for (const [key, replacement] of Object.entries(replacements)) { - let href: URL; - try { - href = new URL(replacement.href); - } catch { - return { ok: false, error: `Invalid href for key: ${key}` }; - } - if (!['https:', 'http:'].includes(href.protocol)) { - return { ok: false, error: `Unsafe href protocol for key: ${key}` }; - } - - validated.push({ - key, - type: replacement.type, - value: replacement.value, - href: replacement.href, - ...(replacement.target && { target: replacement.target }), - }); - } - - return { ok: true, replacements: validated }; +): RichContentLink[] { + return Object.entries(replacements).map(([key, replacement]) => ({ + key, + type: replacement.type, + value: replacement.value, + href: replacement.href, + ...(replacement.target && { target: replacement.target }), + })); } /** @@ -787,66 +771,35 @@ export function returnNoValueCollector< /** * @function returnReadOnlyCollector - Creates a ReadOnlyCollector with pass-through rich content. - * When richContent is present, validates replacements and passes through the template. - * When absent, richContent echoes the plain content with empty replacements. + * When richContent is present, the template and normalized replacements are passed through + * unmodified. When absent, richContent echoes the plain content with empty replacements. * * @param {ReadOnlyField} field - The LABEL field from the API response. * @param {number} idx - The index to be used in the id of the ReadOnlyCollector. - * @returns {ReadOnlyCollectorBase} The constructed ReadOnlyCollector. + * @returns {ReadOnlyCollector} The constructed ReadOnlyCollector. */ -export function returnReadOnlyCollector(field: ReadOnlyField, idx: number): ReadOnlyCollectorBase { - const fieldErrors = [ - ...(!('content' in field) ? ['Content is not found in the field object.'] : []), - ...(!('type' in field) ? ['Type is not found in the field object.'] : []), - ]; - - const id = `${field.key || field.type}-${idx}`; +export function returnReadOnlyCollector(field: ReadOnlyField, idx: number): ReadOnlyCollector { + const base = returnNoValueCollector(field, idx, 'ReadOnlyCollector'); if (!field.richContent) { - const errors = fieldErrors; return { - category: 'NoValueCollector', - error: errors.length > 0 ? errors.join(' ') : null, - type: 'ReadOnlyCollector', - id, - name: id, + ...base, output: { - key: id, - label: field.content, - type: field.type, + ...base.output, content: field.content, richContent: { content: field.content, replacements: [] }, }, }; } - // Validate that all {{key}} references in the template have corresponding replacements - const templateKeys = [...field.richContent.content.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]); - const apiReplacements = field.richContent.replacements ?? {}; - const missingKeys = templateKeys.filter((k) => !(k in apiReplacements)); - const templateErrors = missingKeys.map((k) => `Missing replacement for key: {{${k}}}`); - - const validationResult = - templateErrors.length === 0 ? validateReplacements(apiReplacements) : null; - - const replacements = validationResult?.ok ? validationResult.replacements : []; - const validationErrors = validationResult && !validationResult.ok ? [validationResult.error] : []; - const errors = [...fieldErrors, ...templateErrors, ...validationErrors]; - return { - category: 'NoValueCollector', - error: errors.length > 0 ? errors.join(' ') : null, - type: 'ReadOnlyCollector', - id, - name: id, + ...base, output: { - key: id, - label: field.content, - type: field.type, + ...base.output, content: field.content, richContent: { content: field.richContent.content, - replacements, + replacements: normalizeReplacements(field.richContent.replacements ?? {}), }, }, }; @@ -856,9 +809,9 @@ export function returnReadOnlyCollector(field: ReadOnlyField, idx: number): Read * @function returnQrCodeCollector - Creates a QrCodeCollector object for displaying QR code images. * @param {QrCodeField} field - The field object containing key, content, type, and optional fallbackText. * @param {number} idx - The index to be used in the id of the QrCodeCollector. - * @returns {QrCodeCollectorBase} The constructed QrCodeCollector object. + * @returns {QrCodeCollector} The constructed QrCodeCollector object. */ -export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCollectorBase { +export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCollector { const base = returnNoValueCollector(field, idx, 'QrCodeCollector'); return { diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 010980333c..8423958add 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -68,6 +68,11 @@ export type StandardField = { required?: boolean; }; +/** + * A single replacement entry in the raw DaVinci `richContent.replacements` map. + * The map's key (set on the parent `RichContent`) corresponds to the `{{key}}` + * token in `content`. Currently only `link` is supported. + */ export type RichContentReplacement = { type: 'link'; value: string; @@ -75,11 +80,22 @@ export type RichContentReplacement = { target?: '_self' | '_blank'; }; +/** + * Raw rich-content payload as returned by DaVinci on a LABEL field. + * `content` is a template string with `{{key}}` tokens; `replacements` maps + * each key to its substitution data. Validated and normalized into + * `CollectorRichContent` by the SDK. + */ export type RichContent = { content: string; replacements?: Record; }; +/** + * The shape of a LABEL field in a DaVinci form. `content` is the plain-text + * fallback; `richContent`, when present, carries a template + replacement data + * for rendering inline links. + */ export type ReadOnlyField = { type: 'LABEL'; content: string; From 08a8bcdfa1dfc9680138fd5ca6f764e5f2e32e87 Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:07:12 +0000 Subject: [PATCH 4/4] chore: pr-comments [Self-Healing CI Rerun]