Skip to content

Commit df2e9e2

Browse files
committed
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 <p>. - 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.
1 parent 44f9be3 commit df2e9e2

16 files changed

Lines changed: 842 additions & 122 deletions

.changeset/rich-content-links.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@forgerock/davinci-client': minor
3+
---
4+
5+
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.
6+
7+
**New type exports**: `RichContentLink`, `ValidatedReplacement`, `CollectorRichContent`

.nxignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.opensource/
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 1e3f0d7de2572ae5a0433525c5af65c73c031e67

e2e/davinci-app/components/label.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,56 @@
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
66
*/
7-
import type { ReadOnlyCollector } from '@forgerock/davinci-client/types';
7+
import type { ReadOnlyCollector, RichTextCollector } from '@forgerock/davinci-client/types';
88

9-
export default function (formEl: HTMLFormElement, collector: ReadOnlyCollector) {
10-
// create paragraph element with text of "Loading ... "
9+
export default function (
10+
formEl: HTMLFormElement,
11+
collector: ReadOnlyCollector | RichTextCollector,
12+
) {
1113
const p = document.createElement('p');
14+
p.style.whiteSpace = 'pre-line';
15+
16+
if (collector.type !== 'RichTextCollector') {
17+
p.innerText = collector.output.content;
18+
formEl?.appendChild(p);
19+
return;
20+
}
21+
22+
const { richContent } = collector.output;
23+
24+
if (richContent.replacements.length === 0) {
25+
p.innerText = collector.output.content;
26+
formEl?.appendChild(p);
27+
return;
28+
}
29+
30+
// Interpolate the template by splitting on {{key}} and inserting links
31+
const segments = richContent.content.split(/\{\{(\w+)\}\}/);
32+
const replacementMap = new Map(richContent.replacements.map((r) => [r.key, r]));
33+
34+
for (let i = 0; i < segments.length; i++) {
35+
if (i % 2 === 0) {
36+
// Text segment
37+
if (segments[i]) {
38+
p.appendChild(document.createTextNode(segments[i]));
39+
}
40+
} else {
41+
// Replacement key
42+
const replacement = replacementMap.get(segments[i]);
43+
if (replacement?.type === 'link') {
44+
const a = document.createElement('a');
45+
a.href = replacement.href;
46+
a.textContent = replacement.value;
47+
if (replacement.target) {
48+
a.target = replacement.target;
49+
if (replacement.target === '_blank') {
50+
a.rel = 'noopener noreferrer';
51+
}
52+
}
53+
p.appendChild(a);
54+
}
55+
}
56+
}
1257

13-
p.innerText = collector.output.label;
1458
formEl?.appendChild(p);
1559
}

e2e/davinci-app/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ const urlParams = new URLSearchParams(window.location.search);
221221
davinciClient.update(collector), // Returns an update function for this collector
222222
submitForm,
223223
);
224-
} else if (collector.type === 'ReadOnlyCollector') {
224+
} else if (collector.type === 'ReadOnlyCollector' || collector.type === 'RichTextCollector') {
225225
labelComponent(
226226
formEl, // You can ignore this; it's just for rendering
227227
collector, // This is the plain object of the collector

packages/davinci-client/api-report/davinci-client.api.md

Lines changed: 132 additions & 42 deletions
Large diffs are not rendered by default.

packages/davinci-client/api-report/davinci-client.types.api.md

Lines changed: 132 additions & 42 deletions
Large diffs are not rendered by default.

packages/davinci-client/src/lib/collector.types.test-d.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
InferActionCollectorType,
2626
InferNoValueCollectorType,
2727
ReadOnlyCollector,
28+
RichTextCollector,
2829
QrCodeCollector,
2930
AgreementCollector,
3031
PhoneNumberCollector,
@@ -35,6 +36,9 @@ import type {
3536
PhoneNumberOutputValue,
3637
PhoneNumberExtensionInputValue,
3738
PhoneNumberExtensionOutputValue,
39+
RichContentLink,
40+
CollectorRichContent,
41+
NoValueCollector,
3842
} from './collector.types.js';
3943

4044
describe('Collector Types', () => {
@@ -486,12 +490,32 @@ describe('Collector Types', () => {
486490
key: 'read-only',
487491
label: 'Read Only Field',
488492
type: 'READ_ONLY',
493+
content: '',
489494
},
490495
};
491496

492497
expectTypeOf(tCollector).toEqualTypeOf<ReadOnlyCollector>();
493498
});
494499

500+
it('should correctly infer RichTextCollector Type', () => {
501+
const tCollector: InferNoValueCollectorType<'RichTextCollector'> = {
502+
category: 'NoValueCollector',
503+
error: null,
504+
type: 'RichTextCollector',
505+
id: 'rich-text-0',
506+
name: 'rich-text-0',
507+
output: {
508+
key: 'rich-text-0',
509+
label: 'Rich Text Field',
510+
type: 'LABEL',
511+
content: '',
512+
richContent: { content: '', replacements: [] },
513+
},
514+
};
515+
516+
expectTypeOf(tCollector).toEqualTypeOf<RichTextCollector>();
517+
});
518+
495519
it('should correctly infer QrCodeCollector Type', () => {
496520
const tCollector: InferNoValueCollectorType<'QrCodeCollector'> = {
497521
category: 'NoValueCollector',
@@ -534,4 +558,96 @@ describe('Collector Types', () => {
534558
expectTypeOf(tCollector).toEqualTypeOf<AgreementCollector>();
535559
});
536560
});
561+
562+
describe('Rich Content Types', () => {
563+
describe('RichContentLink', () => {
564+
it('should require key, type, value, and href', () => {
565+
expectTypeOf<RichContentLink>().toHaveProperty('key').toBeString();
566+
expectTypeOf<RichContentLink>().toHaveProperty('type').toEqualTypeOf<'link'>();
567+
expectTypeOf<RichContentLink>().toHaveProperty('value').toBeString();
568+
expectTypeOf<RichContentLink>().toHaveProperty('href').toBeString();
569+
});
570+
571+
it('should have optional target constrained to _self or _blank', () => {
572+
expectTypeOf<RichContentLink>()
573+
.toHaveProperty('target')
574+
.toEqualTypeOf<'_self' | '_blank' | undefined>();
575+
});
576+
});
577+
578+
describe('CollectorRichContent', () => {
579+
it('should have required content string and replacements array', () => {
580+
expectTypeOf<CollectorRichContent>().toHaveProperty('content').toBeString();
581+
expectTypeOf<CollectorRichContent>()
582+
.toHaveProperty('replacements')
583+
.toEqualTypeOf<RichContentLink[]>();
584+
});
585+
});
586+
587+
describe('ReadOnlyCollector', () => {
588+
it('should have content as string', () => {
589+
expectTypeOf<ReadOnlyCollector['output']['content']>().toBeString();
590+
});
591+
592+
it('should not have richContent', () => {
593+
expectTypeOf<ReadOnlyCollector['output']>().not.toHaveProperty('richContent');
594+
});
595+
596+
it('should have standard collector fields', () => {
597+
expectTypeOf<ReadOnlyCollector>()
598+
.toHaveProperty('category')
599+
.toEqualTypeOf<'NoValueCollector'>();
600+
expectTypeOf<ReadOnlyCollector>()
601+
.toHaveProperty('type')
602+
.toEqualTypeOf<'ReadOnlyCollector'>();
603+
expectTypeOf<ReadOnlyCollector>().toHaveProperty('error').toEqualTypeOf<string | null>();
604+
});
605+
});
606+
607+
describe('RichTextCollector', () => {
608+
it('should have content as string', () => {
609+
expectTypeOf<RichTextCollector['output']['content']>().toBeString();
610+
});
611+
612+
it('should have required richContent with CollectorRichContent shape', () => {
613+
expectTypeOf<
614+
RichTextCollector['output']['richContent']
615+
>().toEqualTypeOf<CollectorRichContent>();
616+
});
617+
618+
it('should have standard collector fields', () => {
619+
expectTypeOf<RichTextCollector>()
620+
.toHaveProperty('category')
621+
.toEqualTypeOf<'NoValueCollector'>();
622+
expectTypeOf<RichTextCollector>()
623+
.toHaveProperty('type')
624+
.toEqualTypeOf<'RichTextCollector'>();
625+
expectTypeOf<RichTextCollector>().toHaveProperty('error').toEqualTypeOf<string | null>();
626+
});
627+
});
628+
629+
describe("NoValueCollector<'ReadOnlyCollector'>", () => {
630+
it('should resolve to ReadOnlyCollector', () => {
631+
expectTypeOf<NoValueCollector<'ReadOnlyCollector'>>().toEqualTypeOf<ReadOnlyCollector>();
632+
});
633+
634+
it('should have content on output but no richContent', () => {
635+
type Resolved = NoValueCollector<'ReadOnlyCollector'>;
636+
expectTypeOf<Resolved['output']['content']>().toBeString();
637+
expectTypeOf<Resolved['output']>().not.toHaveProperty('richContent');
638+
});
639+
});
640+
641+
describe("NoValueCollector<'RichTextCollector'>", () => {
642+
it('should resolve to RichTextCollector', () => {
643+
expectTypeOf<NoValueCollector<'RichTextCollector'>>().toEqualTypeOf<RichTextCollector>();
644+
});
645+
646+
it('should have content and richContent on output', () => {
647+
type Resolved = NoValueCollector<'RichTextCollector'>;
648+
expectTypeOf<Resolved['output']['content']>().toBeString();
649+
expectTypeOf<Resolved['output']['richContent']>().toEqualTypeOf<CollectorRichContent>();
650+
});
651+
});
652+
});
537653
});

packages/davinci-client/src/lib/collector.types.ts

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ export type SubmitCollector = ActionCollectorNoUrl<'SubmitCollector'>;
521521
*/
522522
export type NoValueCollectorTypes =
523523
| 'ReadOnlyCollector'
524+
| 'RichTextCollector'
524525
| 'NoValueCollector'
525526
| 'QrCodeCollector'
526527
| 'AgreementCollector';
@@ -538,20 +539,65 @@ export interface NoValueCollectorBase<T extends NoValueCollectorTypes> {
538539
};
539540
}
540541

541-
export interface QrCodeCollectorBase {
542-
category: 'NoValueCollector';
543-
error: string | null;
544-
type: 'QrCodeCollector';
545-
id: string;
546-
name: string;
547-
output: {
548-
key: string;
549-
label: string;
550-
type: string;
542+
/**
543+
* @interface RichContentLink - A hyperlink replacement embedded inside a
544+
* `RichTextCollector` template. The `key` matches the `{{key}}` token in the
545+
* template; `href` is passed through from DaVinci unmodified — consumers are
546+
* responsible for sanitizing it before rendering.
547+
*/
548+
export interface RichContentLink {
549+
key: string;
550+
type: 'link';
551+
value: string;
552+
href: string;
553+
target?: '_self' | '_blank';
554+
}
555+
556+
/**
557+
* @interface CollectorRichContent - The normalized rich-content payload exposed on a
558+
* `RichTextCollector`. `content` holds the raw template (with `{{key}}` tokens), and
559+
* `replacements` is the array of substitution entries (the API's keyed Record flattened
560+
* into an array, with the original key carried on each entry).
561+
*/
562+
export interface CollectorRichContent {
563+
content: string;
564+
replacements: RichContentLink[];
565+
}
566+
567+
/**
568+
* @interface QrCodeCollector - Collector for displaying a QR code image. Extends the
569+
* generic `NoValueCollectorBase` with the image `src` on `output`.
570+
*/
571+
export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> {
572+
output: NoValueCollectorBase<'QrCodeCollector'>['output'] & {
551573
src: string;
552574
};
553575
}
554576

577+
/**
578+
* @interface ReadOnlyCollector - Display-only collector for plain LABEL fields.
579+
* Extends `NoValueCollectorBase` with the plain-text `content` from the field.
580+
*/
581+
export interface ReadOnlyCollector extends NoValueCollectorBase<'ReadOnlyCollector'> {
582+
output: NoValueCollectorBase<'ReadOnlyCollector'>['output'] & {
583+
content: string;
584+
};
585+
}
586+
587+
/**
588+
* @interface RichTextCollector - Display-only collector for LABEL fields that carry
589+
* inline link replacements. Extends `NoValueCollectorBase` with the plain-text
590+
* `content` fallback and a structured `richContent` payload (template +
591+
* normalized replacements). Use this type — not `ReadOnlyCollector` — when you
592+
* need to render `{{key}}` tokens as anchor elements.
593+
*/
594+
export interface RichTextCollector extends NoValueCollectorBase<'RichTextCollector'> {
595+
output: NoValueCollectorBase<'RichTextCollector'>['output'] & {
596+
content: string;
597+
richContent: CollectorRichContent;
598+
};
599+
}
600+
555601
export interface AgreementCollector extends NoValueCollectorBase<'AgreementCollector'> {
556602
output: {
557603
key: string;
@@ -576,24 +622,23 @@ export interface AgreementCollector extends NoValueCollectorBase<'AgreementColle
576622
*/
577623
export type InferNoValueCollectorType<T extends NoValueCollectorTypes> =
578624
T extends 'ReadOnlyCollector'
579-
? NoValueCollectorBase<'ReadOnlyCollector'>
580-
: T extends 'QrCodeCollector'
581-
? QrCodeCollectorBase
582-
: T extends 'AgreementCollector'
583-
? AgreementCollector
584-
: NoValueCollectorBase<'NoValueCollector'>;
625+
? ReadOnlyCollector
626+
: T extends 'RichTextCollector'
627+
? RichTextCollector
628+
: T extends 'QrCodeCollector'
629+
? QrCodeCollector
630+
: T extends 'AgreementCollector'
631+
? AgreementCollector
632+
: NoValueCollectorBase<'NoValueCollector'>;
585633

586634
export type NoValueCollectors =
587635
| NoValueCollectorBase<'NoValueCollector'>
588-
| NoValueCollectorBase<'ReadOnlyCollector'>
589-
| QrCodeCollectorBase
636+
| ReadOnlyCollector
637+
| RichTextCollector
638+
| QrCodeCollector
590639
| AgreementCollector;
591640

592-
export type NoValueCollector<T extends NoValueCollectorTypes> = NoValueCollectorBase<T>;
593-
594-
export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>;
595-
596-
export type QrCodeCollector = QrCodeCollectorBase;
641+
export type NoValueCollector<T extends NoValueCollectorTypes> = InferNoValueCollectorType<T>;
597642

598643
/** *********************************************************************
599644
* UNKNOWN COLLECTOR

0 commit comments

Comments
 (0)