From df2e9e24ec906bf3275ee317821e289dc1ac8460 Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Mon, 13 Apr 2026 13:10:40 -0600 Subject: [PATCH] feat(davinci-client): add RichContent types to ReadOnlyField Support RichContent link types by creating a NoValueCollector for it - 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

. - ReadOnlyCollector now represents plain-text LABEL fields only. RichTextCollector is a new dedicated type for LABEL fields with richContent, so consumers can discriminate on collector.type without a breaking change to the existing ReadOnlyCollector output shape. --- .changeset/rich-content-links.md | 7 + .nxignore | 1 + .opensource/forgerock-javascript-sdk | 1 + e2e/davinci-app/components/label.ts | 52 +++- e2e/davinci-app/main.ts | 2 +- .../api-report/davinci-client.api.md | 174 ++++++++++---- .../api-report/davinci-client.types.api.md | 174 ++++++++++---- .../src/lib/collector.types.test-d.ts | 116 +++++++++ .../davinci-client/src/lib/collector.types.ts | 91 +++++-- .../src/lib/collector.utils.test.ts | 224 +++++++++++++++++- .../davinci-client/src/lib/collector.utils.ts | 71 +++++- .../davinci-client/src/lib/davinci.types.ts | 29 +++ .../lib/mock-data/mock-form-fields.data.ts | 16 ++ .../davinci-client/src/lib/node.reducer.ts | 2 + .../src/lib/node.types.test-d.ts | 2 + packages/davinci-client/src/lib/node.types.ts | 2 + 16 files changed, 842 insertions(+), 122 deletions(-) create mode 100644 .changeset/rich-content-links.md create mode 100644 .nxignore create mode 160000 .opensource/forgerock-javascript-sdk diff --git a/.changeset/rich-content-links.md b/.changeset/rich-content-links.md new file mode 100644 index 0000000000..79cbd57daa --- /dev/null +++ b/.changeset/rich-content-links.md @@ -0,0 +1,7 @@ +--- +'@forgerock/davinci-client': minor +--- + +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. + +**New type exports**: `RichContentLink`, `ValidatedReplacement`, `CollectorRichContent` diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000000..0467d38052 --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +.opensource/ 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/e2e/davinci-app/components/label.ts b/e2e/davinci-app/components/label.ts index 29fc355fe7..f62bdc5e38 100644 --- a/e2e/davinci-app/components/label.ts +++ b/e2e/davinci-app/components/label.ts @@ -4,12 +4,56 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import type { ReadOnlyCollector } from '@forgerock/davinci-client/types'; +import type { ReadOnlyCollector, RichTextCollector } from '@forgerock/davinci-client/types'; -export default function (formEl: HTMLFormElement, collector: ReadOnlyCollector) { - // create paragraph element with text of "Loading ... " +export default function ( + formEl: HTMLFormElement, + collector: ReadOnlyCollector | RichTextCollector, +) { const p = document.createElement('p'); + p.style.whiteSpace = 'pre-line'; + + if (collector.type !== 'RichTextCollector') { + p.innerText = collector.output.content; + formEl?.appendChild(p); + return; + } + + 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/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index 119a4dec6e..dcffa37659 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -221,7 +221,7 @@ const urlParams = new URLSearchParams(window.location.search); davinciClient.update(collector), // Returns an update function for this collector submitForm, ); - } else if (collector.type === 'ReadOnlyCollector') { + } else if (collector.type === 'ReadOnlyCollector' || collector.type === 'RichTextCollector') { labelComponent( formEl, // You can ignore this; it's just for rendering collector, // This is the plain object of the collector diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..6b23ee6904 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -177,8 +177,16 @@ 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; +export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -212,7 +220,7 @@ export type CollectorValueType = T extends { } ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; // @public (undocumented) export interface ContinueNode { @@ -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; + pollStatus: (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,13 +1037,13 @@ 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 'RichTextCollector' ? RichTextCollector : 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'>; // @public (undocumented) -export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; +export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; // @public (undocumented) export type InitFlow = () => Promise; @@ -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 | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | RichTextCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | RichTextCollector | 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,10 +1209,10 @@ export interface NoValueCollectorBase { } // @public (undocumented) -export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | NoValueCollectorBase<'ReadOnlyCollector'> | QrCodeCollectorBase | AgreementCollector; +export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector; // @public -export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; +export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; // @public export interface OAuthDetails { @@ -1283,10 +1291,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; // @public (undocumented) -export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; +export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; // @public -export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; +export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; // @public (undocumented) export interface ObjectValueCollectorWithObjectValue, OV = Record> { @@ -1328,13 +1336,68 @@ export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'> // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; +// @public (undocumented) +export interface PhoneNumberExtensionCollector { + // (undocumented) + category: 'ObjectValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: PhoneNumberExtensionInputValue; + type: string; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + extensionLabel: string; + value: PhoneNumberExtensionOutputValue; + }; + // (undocumented) + type: 'PhoneNumberExtensionCollector'; +} + +// @public (undocumented) +export type PhoneNumberExtensionField = PhoneNumberField & { + showExtension: boolean; + extensionLabel: string; +}; + +// @public (undocumented) +export interface PhoneNumberExtensionInputValue { + // (undocumented) + countryCode: string; + // (undocumented) + extension: string; + // (undocumented) + phoneNumber: string; +} + +// @public (undocumented) +export interface PhoneNumberExtensionOutputValue { + // (undocumented) + countryCode?: string; + // (undocumented) + extension?: string; + // (undocumented) + phoneNumber?: string; +} + // @public (undocumented) export type PhoneNumberField = { type: 'PHONE_NUMBER'; key: string; label: string; - defaultCountryCode: string | null; required: boolean; + defaultCountryCode: string | null; validatePhoneNumber: boolean; }; @@ -1415,28 +1478,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 +1494,19 @@ 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; + }; +} -// @public (undocumented) +// @public export type ReadOnlyField = { type: 'LABEL'; content: string; + richContent?: RichContent; key?: string; }; @@ -1473,6 +1526,43 @@ 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 +export interface RichTextCollector extends NoValueCollectorBase<'RichTextCollector'> { + // (undocumented) + output: NoValueCollectorBase<'RichTextCollector'>['output'] & { + content: string; + richContent: CollectorRichContent; + }; +} + // @public (undocumented) export interface SelectorOption { // (undocumented) @@ -1724,7 +1814,7 @@ export type UnknownField = Record; // @public (undocumented) export const updateCollectorValues: ActionCreatorWithPayload< { id: string; -value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; index?: number; }, string>; 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..0ce506e66c 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -177,8 +177,16 @@ 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; +export type Collectors = FlowCollector | PasswordCollector | TextCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -212,7 +220,7 @@ export type CollectorValueType = T extends { } ? string[] : string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; // @public (undocumented) -export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | FidoRegistrationField | FidoAuthenticationField | PollingField; +export type ComplexValueFields = DeviceAuthenticationField | DeviceRegistrationField | PhoneNumberField | PhoneNumberExtensionField | FidoRegistrationField | FidoAuthenticationField | PollingField; // @public (undocumented) export interface ContinueNode { @@ -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; + pollStatus: (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,13 +1034,13 @@ 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 'RichTextCollector' ? RichTextCollector : 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'>; // @public (undocumented) -export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; +export type InferValueObjectCollectorType = T extends 'DeviceAuthenticationCollector' ? DeviceAuthenticationCollector : T extends 'DeviceRegistrationCollector' ? DeviceRegistrationCollector : T extends 'PhoneNumberCollector' ? PhoneNumberCollector : T extends 'PhoneNumberExtensionCollector' ? PhoneNumberExtensionCollector : ObjectOptionsCollectorWithObjectValue<'ObjectValueCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectValueCollector'>; // @public (undocumented) export type InitFlow = () => Promise; @@ -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 | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | RichTextCollector | AgreementCollector | UnknownCollector | ProtectCollector | FidoRegistrationCollector | FidoAuthenticationCollector | PollingCollector | ActionCollector<"ActionCollector"> | SingleValueCollector<"SingleValueCollector">)[]> & { + getInitialState: () => (TextCollector | SingleSelectCollector | ValidatedTextCollector | PasswordCollector | MultiSelectCollector | PhoneNumberExtensionCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | IdpCollector | SubmitCollector | FlowCollector | QrCodeCollector | ReadOnlyCollector | RichTextCollector | 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,10 +1206,10 @@ export interface NoValueCollectorBase { } // @public (undocumented) -export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | NoValueCollectorBase<'ReadOnlyCollector'> | QrCodeCollectorBase | AgreementCollector; +export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector; // @public -export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; +export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; // @public export interface OAuthDetails { @@ -1280,10 +1288,10 @@ export type ObjectValueAutoCollectorTypes = 'ObjectValueAutoCollector' | 'FidoRe export type ObjectValueCollector = ObjectOptionsCollectorWithObjectValue | ObjectOptionsCollectorWithStringValue | ObjectValueCollectorWithObjectValue; // @public (undocumented) -export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; +export type ObjectValueCollectors = DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ObjectOptionsCollectorWithObjectValue<'ObjectSelectCollector'> | ObjectOptionsCollectorWithStringValue<'ObjectSelectCollector'>; // @public -export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; +export type ObjectValueCollectorTypes = 'DeviceAuthenticationCollector' | 'DeviceRegistrationCollector' | 'PhoneNumberCollector' | 'PhoneNumberExtensionCollector' | 'ObjectOptionsCollector' | 'ObjectValueCollector' | 'ObjectSelectCollector'; // @public (undocumented) export interface ObjectValueCollectorWithObjectValue, OV = Record> { @@ -1325,13 +1333,68 @@ export type PasswordCollector = SingleValueCollectorNoValue<'PasswordCollector'> // @public (undocumented) export type PhoneNumberCollector = ObjectValueCollectorWithObjectValue<'PhoneNumberCollector', PhoneNumberInputValue, PhoneNumberOutputValue>; +// @public (undocumented) +export interface PhoneNumberExtensionCollector { + // (undocumented) + category: 'ObjectValueCollector'; + // (undocumented) + error: string | null; + // (undocumented) + id: string; + // (undocumented) + input: { + key: string; + value: PhoneNumberExtensionInputValue; + type: string; + validation: (ValidationRequired | ValidationPhoneNumber)[] | null; + }; + // (undocumented) + name: string; + // (undocumented) + output: { + key: string; + label: string; + type: string; + extensionLabel: string; + value: PhoneNumberExtensionOutputValue; + }; + // (undocumented) + type: 'PhoneNumberExtensionCollector'; +} + +// @public (undocumented) +export type PhoneNumberExtensionField = PhoneNumberField & { + showExtension: boolean; + extensionLabel: string; +}; + +// @public (undocumented) +export interface PhoneNumberExtensionInputValue { + // (undocumented) + countryCode: string; + // (undocumented) + extension: string; + // (undocumented) + phoneNumber: string; +} + +// @public (undocumented) +export interface PhoneNumberExtensionOutputValue { + // (undocumented) + countryCode?: string; + // (undocumented) + extension?: string; + // (undocumented) + phoneNumber?: string; +} + // @public (undocumented) export type PhoneNumberField = { type: 'PHONE_NUMBER'; key: string; label: string; - defaultCountryCode: string | null; required: boolean; + defaultCountryCode: string | null; validatePhoneNumber: boolean; }; @@ -1412,28 +1475,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 +1491,19 @@ 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; + }; +} -// @public (undocumented) +// @public export type ReadOnlyField = { type: 'LABEL'; content: string; + richContent?: RichContent; key?: string; }; @@ -1470,6 +1523,43 @@ 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 +export interface RichTextCollector extends NoValueCollectorBase<'RichTextCollector'> { + // (undocumented) + output: NoValueCollectorBase<'RichTextCollector'>['output'] & { + content: string; + richContent: CollectorRichContent; + }; +} + // @public (undocumented) export interface SelectorOption { // (undocumented) @@ -1721,7 +1811,7 @@ export type UnknownField = Record; // @public (undocumented) export const updateCollectorValues: ActionCreatorWithPayload< { id: string; -value: string | string[] | PhoneNumberInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; +value: string | string[] | PhoneNumberInputValue | PhoneNumberExtensionInputValue | FidoRegistrationInputValue | FidoAuthenticationInputValue; index?: number; }, string>; 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 ecaabcbc33..eea3680de2 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -25,6 +25,7 @@ import type { InferActionCollectorType, InferNoValueCollectorType, ReadOnlyCollector, + RichTextCollector, QrCodeCollector, AgreementCollector, PhoneNumberCollector, @@ -35,6 +36,9 @@ import type { PhoneNumberOutputValue, PhoneNumberExtensionInputValue, PhoneNumberExtensionOutputValue, + RichContentLink, + CollectorRichContent, + NoValueCollector, } from './collector.types.js'; describe('Collector Types', () => { @@ -486,12 +490,32 @@ describe('Collector Types', () => { key: 'read-only', label: 'Read Only Field', type: 'READ_ONLY', + content: '', }, }; expectTypeOf(tCollector).toEqualTypeOf(); }); + it('should correctly infer RichTextCollector Type', () => { + const tCollector: InferNoValueCollectorType<'RichTextCollector'> = { + category: 'NoValueCollector', + error: null, + type: 'RichTextCollector', + id: 'rich-text-0', + name: 'rich-text-0', + output: { + key: 'rich-text-0', + label: 'Rich Text Field', + type: 'LABEL', + content: '', + richContent: { content: '', replacements: [] }, + }, + }; + + expectTypeOf(tCollector).toEqualTypeOf(); + }); + it('should correctly infer QrCodeCollector Type', () => { const tCollector: InferNoValueCollectorType<'QrCodeCollector'> = { category: 'NoValueCollector', @@ -534,4 +558,96 @@ 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', () => { + expectTypeOf().toBeString(); + }); + + it('should not have richContent', () => { + expectTypeOf().not.toHaveProperty('richContent'); + }); + + it('should have standard collector fields', () => { + expectTypeOf() + .toHaveProperty('category') + .toEqualTypeOf<'NoValueCollector'>(); + expectTypeOf() + .toHaveProperty('type') + .toEqualTypeOf<'ReadOnlyCollector'>(); + expectTypeOf().toHaveProperty('error').toEqualTypeOf(); + }); + }); + + describe('RichTextCollector', () => { + it('should have content as string', () => { + expectTypeOf().toBeString(); + }); + + it('should have required richContent with CollectorRichContent shape', () => { + expectTypeOf< + RichTextCollector['output']['richContent'] + >().toEqualTypeOf(); + }); + + it('should have standard collector fields', () => { + expectTypeOf() + .toHaveProperty('category') + .toEqualTypeOf<'NoValueCollector'>(); + expectTypeOf() + .toHaveProperty('type') + .toEqualTypeOf<'RichTextCollector'>(); + expectTypeOf().toHaveProperty('error').toEqualTypeOf(); + }); + }); + + describe("NoValueCollector<'ReadOnlyCollector'>", () => { + it('should resolve to ReadOnlyCollector', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should have content on output but no richContent', () => { + type Resolved = NoValueCollector<'ReadOnlyCollector'>; + expectTypeOf().toBeString(); + expectTypeOf().not.toHaveProperty('richContent'); + }); + }); + + describe("NoValueCollector<'RichTextCollector'>", () => { + it('should resolve to RichTextCollector', () => { + expectTypeOf>().toEqualTypeOf(); + }); + + it('should have content and richContent on output', () => { + type Resolved = NoValueCollector<'RichTextCollector'>; + 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 cb9f8f1787..563ef8c8f1 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -521,6 +521,7 @@ export type SubmitCollector = ActionCollectorNoUrl<'SubmitCollector'>; */ export type NoValueCollectorTypes = | 'ReadOnlyCollector' + | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; @@ -538,20 +539,65 @@ export interface NoValueCollectorBase { }; } -export interface QrCodeCollectorBase { - category: 'NoValueCollector'; - error: string | null; - type: 'QrCodeCollector'; - id: string; - name: string; - output: { - key: string; - label: string; - type: string; +/** + * @interface RichContentLink - A hyperlink replacement embedded inside a + * `RichTextCollector` 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'; + value: string; + href: string; + target?: '_self' | '_blank'; +} + +/** + * @interface CollectorRichContent - The normalized rich-content payload exposed on a + * `RichTextCollector`. `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: RichContentLink[]; +} + +/** + * @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; }; } +/** + * @interface ReadOnlyCollector - Display-only collector for plain LABEL fields. + * Extends `NoValueCollectorBase` with the plain-text `content` from the field. + */ +export interface ReadOnlyCollector extends NoValueCollectorBase<'ReadOnlyCollector'> { + output: NoValueCollectorBase<'ReadOnlyCollector'>['output'] & { + content: string; + }; +} + +/** + * @interface RichTextCollector - Display-only collector for LABEL fields that carry + * inline link replacements. Extends `NoValueCollectorBase` with the plain-text + * `content` fallback and a structured `richContent` payload (template + + * normalized replacements). Use this type — not `ReadOnlyCollector` — when you + * need to render `{{key}}` tokens as anchor elements. + */ +export interface RichTextCollector extends NoValueCollectorBase<'RichTextCollector'> { + output: NoValueCollectorBase<'RichTextCollector'>['output'] & { + content: string; + richContent: CollectorRichContent; + }; +} + export interface AgreementCollector extends NoValueCollectorBase<'AgreementCollector'> { output: { key: string; @@ -576,24 +622,23 @@ export interface AgreementCollector extends NoValueCollectorBase<'AgreementColle */ export type InferNoValueCollectorType = T extends 'ReadOnlyCollector' - ? NoValueCollectorBase<'ReadOnlyCollector'> - : T extends 'QrCodeCollector' - ? QrCodeCollectorBase - : T extends 'AgreementCollector' - ? AgreementCollector - : NoValueCollectorBase<'NoValueCollector'>; + ? ReadOnlyCollector + : T extends 'RichTextCollector' + ? RichTextCollector + : T extends 'QrCodeCollector' + ? QrCodeCollector + : T extends 'AgreementCollector' + ? AgreementCollector + : NoValueCollectorBase<'NoValueCollector'>; export type NoValueCollectors = | NoValueCollectorBase<'NoValueCollector'> - | NoValueCollectorBase<'ReadOnlyCollector'> - | QrCodeCollectorBase + | ReadOnlyCollector + | RichTextCollector + | QrCodeCollector | AgreementCollector; -export type NoValueCollector = NoValueCollectorBase; - -export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>; - -export type QrCodeCollector = QrCodeCollectorBase; +export type NoValueCollector = InferNoValueCollectorType; /** ********************************************************************* * 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 7b47a6f22f..d4c7c58e08 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, + normalizeReplacements, } from './collector.utils.js'; import type { DaVinciField, @@ -38,6 +39,7 @@ import type { PollingField, ReadOnlyField, RedirectField, + RichContentReplacement, StandardField, AgreementField, } from './davinci.types.js'; @@ -47,6 +49,7 @@ import type { PhoneNumberExtensionCollector, PhoneNumberOutputValue, PhoneNumberExtensionOutputValue, + RichTextCollector, ValidatedTextCollector, } from './collector.types.js'; @@ -905,7 +908,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 when no richContent on field', () => { const result = returnReadOnlyCollector(mockField, 0); expect(result).toEqual({ category: 'NoValueCollector', @@ -917,9 +920,83 @@ describe('No Value Collectors', () => { key: 'LABEL-0', label: mockField.content, type: mockField.type, + content: mockField.content, }, }); }); + + it('should return a RichTextCollector when richContent is present', () => { + 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: 'RichTextCollector', + 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 pass through unsafe-looking hrefs unchanged (consumer is responsible for sanitization)', () => { + 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) as RichTextCollector; + + expect(result.error).toBeNull(); + expect(result.type).toBe('RichTextCollector'); + expect(result.output.richContent).toEqual({ + content: 'Click {{bad}}', + replacements: [{ key: 'bad', type: 'link', value: 'here', href: 'javascript:alert(1)' }], + }); + }); }); }); @@ -1290,3 +1367,148 @@ describe('Return collector validator', () => { ); }); }); + +describe('normalizeReplacements', () => { + it('should flatten a single link replacement', () => { + const replacements: Record = { + 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 flatten 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', + }, + }; + + 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', () => { + const replacements: Record = { + 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', () => { + expect(normalizeReplacements({})).toEqual([]); + }); + + it('should pass non-http(s) hrefs through unchanged', () => { + const replacements: Record = { + link: { + type: 'link', + value: 'here', + href: 'javascript:alert(1)', + }, + }; + + expect(normalizeReplacements(replacements)).toEqual([ + { key: 'link', type: 'link', value: 'here', href: 'javascript:alert(1)' }, + ]); + }); +}); + +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) as RichTextCollector; + const checkboxCollector = returnMultiSelectCollector(checkboxField, 1, []); + + // Verify label collector has pass-through richContent + expect(labelCollector.type).toBe('RichTextCollector'); + 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 4d639d9381..c4df7639a9 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -29,7 +29,10 @@ import type { AutoCollectors, SingleValueAutoCollectorTypes, ObjectValueAutoCollectorTypes, - QrCodeCollectorBase, + QrCodeCollector, + ReadOnlyCollector, + RichTextCollector, + RichContentLink, AgreementCollector, PhoneNumberExtensionOutputValue, } from './collector.types.js'; @@ -45,6 +48,7 @@ import type { PollingField, ReadOnlyField, RedirectField, + RichContentReplacement, SingleSelectField, StandardField, ValidatedField, @@ -755,6 +759,27 @@ export function returnObjectValueCollector( ); } +/** + * @function normalizeReplacements - Flattens the API's keyed + * `Record` into an array of `RichContentLink` + * with the original key carried on each entry. Hrefs are passed through + * unmodified — consumers are responsible for sanitizing before rendering. + * + * @param {Record} replacements - The replacements map from the API. + * @returns {RichContentLink[]} The flattened array of replacement entries. + */ +export function normalizeReplacements( + replacements: Record, +): RichContentLink[] { + return Object.entries(replacements).map(([key, replacement]) => ({ + key, + type: replacement.type, + value: replacement.value, + href: replacement.href, + ...(replacement.target && { target: replacement.target }), + })); +} + /** * @function returnNoValueCollector - Creates a NoValueCollector object based on the provided field, index, and optional collector type. * @param {DaVinciField} field - The field object containing key, label, type, and links. @@ -789,22 +814,50 @@ 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. - * @param {number} idx - The index to be used in the id of the ReadOnlyCollector. - * @returns {ReadOnlyCollector} The constructed ReadOnlyCollector object. + * @function returnReadOnlyCollector - Creates a `ReadOnlyCollector` (plain text) or + * `RichTextCollector` (template + link replacements) depending on whether the field + * carries a `richContent` payload. + * + * @param {ReadOnlyField} field - The LABEL field from the API response. + * @param {number} idx - The index to be used in the id of the collector. + * @returns {ReadOnlyCollector | RichTextCollector} The constructed collector. */ -export function returnReadOnlyCollector(field: ReadOnlyField, idx: number) { - return returnNoValueCollector(field, idx, 'ReadOnlyCollector'); +export function returnReadOnlyCollector( + field: ReadOnlyField, + idx: number, +): ReadOnlyCollector | RichTextCollector { + if (field.richContent) { + const base = returnNoValueCollector(field, idx, 'RichTextCollector'); + return { + ...base, + output: { + ...base.output, + content: field.content, + richContent: { + content: field.richContent.content, + replacements: normalizeReplacements(field.richContent.replacements ?? {}), + }, + }, + }; + } + + const base = returnNoValueCollector(field, idx, 'ReadOnlyCollector'); + return { + ...base, + output: { + ...base.output, + content: field.content, + }, + }; } /** * @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 b73488fd3a..d01d2c7343 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -68,9 +68,38 @@ 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; + href: string; + 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; + 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', }, diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index 3c84780761..0913902768 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -44,6 +44,7 @@ import type { SubmitCollector, TextCollector, ReadOnlyCollector, + RichTextCollector, ValidatedTextCollector, DeviceAuthenticationCollector, DeviceRegistrationCollector, @@ -105,6 +106,7 @@ const initialCollectorValues: ( | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector + | RichTextCollector | ValidatedTextCollector | UnknownCollector | ProtectCollector 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 86c3a4ecea..18767f150b 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -22,6 +22,7 @@ import { MultiSelectCollector, PasswordCollector, ReadOnlyCollector, + RichTextCollector, SingleSelectCollector, SingleValueCollector, IdpCollector, @@ -236,6 +237,7 @@ describe('Node Types', () => { | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector + | RichTextCollector | SingleSelectCollector | ValidatedTextCollector | ProtectCollector diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 52759bf695..e27424b55b 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -19,6 +19,7 @@ import type { DeviceAuthenticationCollector, DeviceRegistrationCollector, ReadOnlyCollector, + RichTextCollector, ValidatedTextCollector, PhoneNumberCollector, ProtectCollector, @@ -47,6 +48,7 @@ export type Collectors = | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector + | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector