Skip to content

Commit ac588fa

Browse files
committed
feat(davinci-client): add RichContent types to ReadOnlyField
Support RichContent link types by creating a NoValueCollector for it
1 parent 9088443 commit ac588fa

8 files changed

Lines changed: 675 additions & 12 deletions

File tree

.changeset/rich-content-links.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@forgerock/davinci-client': minor
3+
---
4+
5+
**Breaking change**: `ReadOnlyCollector.output.content` now returns a plain `string` (the label text) instead of `ContentPart[]`.
6+
7+
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.
8+
9+
**Removed type exports**: `ContentPart`, `TextContentPart`, `LinkContentPart`
10+
11+
**New type exports**: `RichContentLink`, `ValidatedReplacement`, `CollectorRichContent`
12+
13+
Includes href protocol validation that rejects unsafe URI schemes (e.g. `javascript:`, `data:`).

e2e/davinci-app/components/label.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,42 @@
77
import type { ReadOnlyCollector } from '@forgerock/davinci-client/types';
88

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

13-
p.innerText = collector.output.label;
1447
formEl?.appendChild(p);
1548
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
import { describe, expectTypeOf, it } from 'vitest';
8+
import type {
9+
ReadOnlyCollectorBase,
10+
ReadOnlyCollector,
11+
RichContentLink,
12+
ValidatedReplacement,
13+
CollectorRichContent,
14+
ValidateReplacementsResult,
15+
NoValueCollector,
16+
} from './collector.types.js';
17+
18+
describe('Rich Content Types', () => {
19+
describe('RichContentLink', () => {
20+
it('should require key, type, value, and href', () => {
21+
expectTypeOf<RichContentLink>().toHaveProperty('key').toBeString();
22+
expectTypeOf<RichContentLink>().toHaveProperty('type').toEqualTypeOf<'link'>();
23+
expectTypeOf<RichContentLink>().toHaveProperty('value').toBeString();
24+
expectTypeOf<RichContentLink>().toHaveProperty('href').toBeString();
25+
});
26+
27+
it('should have optional target constrained to _self or _blank', () => {
28+
expectTypeOf<RichContentLink>()
29+
.toHaveProperty('target')
30+
.toEqualTypeOf<'_self' | '_blank' | undefined>();
31+
});
32+
});
33+
34+
describe('ValidatedReplacement', () => {
35+
it('should be assignable from RichContentLink', () => {
36+
expectTypeOf<RichContentLink>().toMatchTypeOf<ValidatedReplacement>();
37+
});
38+
39+
it('should be assignable to RichContentLink', () => {
40+
expectTypeOf<ValidatedReplacement>().toMatchTypeOf<RichContentLink>();
41+
});
42+
});
43+
44+
describe('CollectorRichContent', () => {
45+
it('should have required content string and replacements array', () => {
46+
expectTypeOf<CollectorRichContent>().toHaveProperty('content').toBeString();
47+
expectTypeOf<CollectorRichContent>()
48+
.toHaveProperty('replacements')
49+
.toEqualTypeOf<ValidatedReplacement[]>();
50+
});
51+
});
52+
53+
describe('ValidateReplacementsResult', () => {
54+
it('should narrow to replacements on ok: true', () => {
55+
const result = {} as ValidateReplacementsResult;
56+
if (result.ok) {
57+
expectTypeOf(result.replacements).toEqualTypeOf<ValidatedReplacement[]>();
58+
}
59+
});
60+
61+
it('should narrow to error on ok: false', () => {
62+
const result = {} as ValidateReplacementsResult;
63+
if (!result.ok) {
64+
expectTypeOf(result.error).toBeString();
65+
}
66+
});
67+
});
68+
69+
describe('ReadOnlyCollectorBase', () => {
70+
it('should have content as string, not array', () => {
71+
expectTypeOf<ReadOnlyCollectorBase['output']['content']>().toBeString();
72+
});
73+
74+
it('should have required richContent with CollectorRichContent shape', () => {
75+
expectTypeOf<
76+
ReadOnlyCollectorBase['output']['richContent']
77+
>().toEqualTypeOf<CollectorRichContent>();
78+
});
79+
80+
it('should have standard collector fields', () => {
81+
expectTypeOf<ReadOnlyCollectorBase>()
82+
.toHaveProperty('category')
83+
.toEqualTypeOf<'NoValueCollector'>();
84+
expectTypeOf<ReadOnlyCollectorBase>()
85+
.toHaveProperty('type')
86+
.toEqualTypeOf<'ReadOnlyCollector'>();
87+
expectTypeOf<ReadOnlyCollectorBase>().toHaveProperty('error').toEqualTypeOf<string | null>();
88+
});
89+
});
90+
91+
describe('NoValueCollector<ReadOnlyCollector>', () => {
92+
it('should resolve to ReadOnlyCollectorBase', () => {
93+
expectTypeOf<NoValueCollector<'ReadOnlyCollector'>>().toEqualTypeOf<ReadOnlyCollectorBase>();
94+
});
95+
96+
it('should have content and richContent on output', () => {
97+
type Resolved = NoValueCollector<'ReadOnlyCollector'>;
98+
expectTypeOf<Resolved['output']['content']>().toBeString();
99+
expectTypeOf<Resolved['output']['richContent']>().toEqualTypeOf<CollectorRichContent>();
100+
});
101+
});
102+
103+
describe('ReadOnlyCollector alias', () => {
104+
it('should equal ReadOnlyCollectorBase', () => {
105+
expectTypeOf<ReadOnlyCollector>().toEqualTypeOf<ReadOnlyCollectorBase>();
106+
});
107+
});
108+
});

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,25 @@ export interface NoValueCollectorBase<T extends NoValueCollectorTypes> {
497497
};
498498
}
499499

500+
export interface RichContentLink {
501+
key: string;
502+
type: 'link';
503+
value: string;
504+
href: string;
505+
target?: '_self' | '_blank';
506+
}
507+
508+
export type ValidatedReplacement = RichContentLink;
509+
510+
export interface CollectorRichContent {
511+
content: string;
512+
replacements: ValidatedReplacement[];
513+
}
514+
515+
export type ValidateReplacementsResult =
516+
| { ok: true; replacements: ValidatedReplacement[] }
517+
| { ok: false; error: string };
518+
500519
export interface QrCodeCollectorBase {
501520
category: 'NoValueCollector';
502521
error: string | null;
@@ -511,6 +530,21 @@ export interface QrCodeCollectorBase {
511530
};
512531
}
513532

533+
export interface ReadOnlyCollectorBase {
534+
category: 'NoValueCollector';
535+
error: string | null;
536+
type: 'ReadOnlyCollector';
537+
id: string;
538+
name: string;
539+
output: {
540+
key: string;
541+
label: string;
542+
type: string;
543+
content: string;
544+
richContent: CollectorRichContent;
545+
};
546+
}
547+
514548
/**
515549
* Type to help infer the collector based on the collector type
516550
* Used specifically in the returnNoValueCollector wrapper function.
@@ -520,19 +554,19 @@ export interface QrCodeCollectorBase {
520554
*/
521555
export type InferNoValueCollectorType<T extends NoValueCollectorTypes> =
522556
T extends 'ReadOnlyCollector'
523-
? NoValueCollectorBase<'ReadOnlyCollector'>
557+
? ReadOnlyCollectorBase
524558
: T extends 'QrCodeCollector'
525559
? QrCodeCollectorBase
526560
: NoValueCollectorBase<'NoValueCollector'>;
527561

528562
export type NoValueCollectors =
529563
| NoValueCollectorBase<'NoValueCollector'>
530-
| NoValueCollectorBase<'ReadOnlyCollector'>
564+
| ReadOnlyCollectorBase
531565
| QrCodeCollectorBase;
532566

533-
export type NoValueCollector<T extends NoValueCollectorTypes> = NoValueCollectorBase<T>;
567+
export type NoValueCollector<T extends NoValueCollectorTypes> = InferNoValueCollectorType<T>;
534568

535-
export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>;
569+
export type ReadOnlyCollector = ReadOnlyCollectorBase;
536570

537571
export type QrCodeCollector = QrCodeCollectorBase;
538572

0 commit comments

Comments
 (0)