Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/rich-content-links.md
Original file line number Diff line number Diff line change
@@ -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`
1 change: 1 addition & 0 deletions .nxignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.opensource/
1 change: 1 addition & 0 deletions .opensource/forgerock-javascript-sdk
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What is this file? Do we want to include it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Technically yes, we all need it but its the submodule of the legacy sdk for the interface mapping.

Submodule forgerock-javascript-sdk added at 1e3f0d
42 changes: 38 additions & 4 deletions e2e/davinci-app/components/label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,46 @@
* 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 { 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: RichTextCollector) {
const p = document.createElement('p');
p.style.whiteSpace = 'pre-line';
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);
}
}
Comment on lines +20 to +45
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Preserve unmatched placeholders instead of dropping them.

The loop currently skips any token without a matching link replacement, so a typo or unsupported placeholder silently disappears from the rendered label. The \w+ matcher also excludes keys containing punctuation.

♻️ Suggested fix
       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);
+      } else {
+        p.appendChild(document.createTextNode(`{{${segments[i]}}}`));
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/davinci-app/components/label.ts` around lines 20 - 45, The loop currently
drops unmatched placeholders and the regex /\{\{(\w+)\}\}/ only matches word
chars; change the regex to capture any non-} key (e.g. /\{\{([^}]+)\}\}/) so
keys with punctuation are preserved, and when no replacement is found
(replacementMap.get(...) is undefined) append the original placeholder string
(`'{{' + key + '}}'`) as a text node instead of skipping it; update the code
locations using variables/identifiers segments, replacementMap,
richContent.content and richContent.replacements, and the for loop handling
replacement/type === 'link' to implement this behavior.

}

p.innerText = collector.output.label;
formEl?.appendChild(p);
}
174 changes: 132 additions & 42 deletions packages/davinci-client/api-report/davinci-client.api.md

Large diffs are not rendered by default.

174 changes: 132 additions & 42 deletions packages/davinci-client/api-report/davinci-client.types.api.md

Large diffs are not rendered by default.

116 changes: 116 additions & 0 deletions packages/davinci-client/src/lib/collector.types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
InferActionCollectorType,
InferNoValueCollectorType,
ReadOnlyCollector,
RichTextCollector,
QrCodeCollector,
AgreementCollector,
PhoneNumberCollector,
Expand All @@ -35,6 +36,9 @@ import type {
PhoneNumberOutputValue,
PhoneNumberExtensionInputValue,
PhoneNumberExtensionOutputValue,
RichContentLink,
CollectorRichContent,
NoValueCollector,
} from './collector.types.js';

describe('Collector Types', () => {
Expand Down Expand Up @@ -486,12 +490,32 @@ describe('Collector Types', () => {
key: 'read-only',
label: 'Read Only Field',
type: 'READ_ONLY',
content: '',
},
};

expectTypeOf(tCollector).toEqualTypeOf<ReadOnlyCollector>();
});

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

it('should correctly infer QrCodeCollector Type', () => {
const tCollector: InferNoValueCollectorType<'QrCodeCollector'> = {
category: 'NoValueCollector',
Expand Down Expand Up @@ -534,4 +558,96 @@ describe('Collector Types', () => {
expectTypeOf(tCollector).toEqualTypeOf<AgreementCollector>();
});
});

describe('Rich Content Types', () => {
describe('RichContentLink', () => {
it('should require key, type, value, and href', () => {
expectTypeOf<RichContentLink>().toHaveProperty('key').toBeString();
expectTypeOf<RichContentLink>().toHaveProperty('type').toEqualTypeOf<'link'>();
expectTypeOf<RichContentLink>().toHaveProperty('value').toBeString();
expectTypeOf<RichContentLink>().toHaveProperty('href').toBeString();
});

it('should have optional target constrained to _self or _blank', () => {
expectTypeOf<RichContentLink>()
.toHaveProperty('target')
.toEqualTypeOf<'_self' | '_blank' | undefined>();
});
});

describe('CollectorRichContent', () => {
it('should have required content string and replacements array', () => {
expectTypeOf<CollectorRichContent>().toHaveProperty('content').toBeString();
expectTypeOf<CollectorRichContent>()
.toHaveProperty('replacements')
.toEqualTypeOf<RichContentLink[]>();
});
});

describe('ReadOnlyCollector', () => {
it('should have content as string', () => {
expectTypeOf<ReadOnlyCollector['output']['content']>().toBeString();
});

it('should not have richContent', () => {
expectTypeOf<ReadOnlyCollector['output']>().not.toHaveProperty('richContent');
});

it('should have standard collector fields', () => {
expectTypeOf<ReadOnlyCollector>()
.toHaveProperty('category')
.toEqualTypeOf<'NoValueCollector'>();
expectTypeOf<ReadOnlyCollector>()
.toHaveProperty('type')
.toEqualTypeOf<'ReadOnlyCollector'>();
expectTypeOf<ReadOnlyCollector>().toHaveProperty('error').toEqualTypeOf<string | null>();
});
});

describe('RichTextCollector', () => {
it('should have content as string', () => {
expectTypeOf<RichTextCollector['output']['content']>().toBeString();
});

it('should have required richContent with CollectorRichContent shape', () => {
expectTypeOf<
RichTextCollector['output']['richContent']
>().toEqualTypeOf<CollectorRichContent>();
});

it('should have standard collector fields', () => {
expectTypeOf<RichTextCollector>()
.toHaveProperty('category')
.toEqualTypeOf<'NoValueCollector'>();
expectTypeOf<RichTextCollector>()
.toHaveProperty('type')
.toEqualTypeOf<'RichTextCollector'>();
expectTypeOf<RichTextCollector>().toHaveProperty('error').toEqualTypeOf<string | null>();
});
});

describe("NoValueCollector<'ReadOnlyCollector'>", () => {
it('should resolve to ReadOnlyCollector', () => {
expectTypeOf<NoValueCollector<'ReadOnlyCollector'>>().toEqualTypeOf<ReadOnlyCollector>();
});

it('should have content on output but no richContent', () => {
type Resolved = NoValueCollector<'ReadOnlyCollector'>;
expectTypeOf<Resolved['output']['content']>().toBeString();
expectTypeOf<Resolved['output']>().not.toHaveProperty('richContent');
});
});

describe("NoValueCollector<'RichTextCollector'>", () => {
it('should resolve to RichTextCollector', () => {
expectTypeOf<NoValueCollector<'RichTextCollector'>>().toEqualTypeOf<RichTextCollector>();
});

it('should have content and richContent on output', () => {
type Resolved = NoValueCollector<'RichTextCollector'>;
expectTypeOf<Resolved['output']['content']>().toBeString();
expectTypeOf<Resolved['output']['richContent']>().toEqualTypeOf<CollectorRichContent>();
});
});
});
});
91 changes: 68 additions & 23 deletions packages/davinci-client/src/lib/collector.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ export type SubmitCollector = ActionCollectorNoUrl<'SubmitCollector'>;
*/
export type NoValueCollectorTypes =
| 'ReadOnlyCollector'
| 'RichTextCollector'
| 'NoValueCollector'
| 'QrCodeCollector'
| 'AgreementCollector';
Expand All @@ -538,20 +539,65 @@ export interface NoValueCollectorBase<T extends NoValueCollectorTypes> {
};
}

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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rather than adding more properties to the base collector, can we just add a new collector type for this rich content collector? This new collector can just extent the base collector adding the new property on the output. Does that make sense?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can I get a response on this? I feel like having a RichTextCollector dedicated to this is important. It communicates to the developer that this is functionally different from a regular text collector, especially since the actual content they want is not the input.content.

};
}

export interface AgreementCollector extends NoValueCollectorBase<'AgreementCollector'> {
output: {
key: string;
Expand All @@ -576,24 +622,23 @@ export interface AgreementCollector extends NoValueCollectorBase<'AgreementColle
*/
export type InferNoValueCollectorType<T extends NoValueCollectorTypes> =
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<T extends NoValueCollectorTypes> = NoValueCollectorBase<T>;

export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>;

export type QrCodeCollector = QrCodeCollectorBase;
export type NoValueCollector<T extends NoValueCollectorTypes> = InferNoValueCollectorType<T>;

/** *********************************************************************
* UNKNOWN COLLECTOR
Expand Down
Loading
Loading