Skip to content
Draft
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
13 changes: 13 additions & 0 deletions .changeset/rich-content-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@forgerock/davinci-client': minor
---

**Breaking change**: `ReadOnlyCollector.output.content` now returns a plain `string` (the label text) instead of `ContentPart[]`.

A new `ReadOnlyCollector.output.richContent` property is always present and contains the structured link data when a LABEL field includes `richContent`. Its shape is `CollectorRichContent` — a template string with `{{key}}` placeholders (`content`) and a validated `replacements` array (`ValidatedReplacement[]`). When no `richContent` is present, `replacements` is an empty array.

**Removed type exports**: `ContentPart`, `TextContentPart`, `LinkContentPart`

**New type exports**: `RichContentLink`, `ValidatedReplacement`, `CollectorRichContent`

Includes href protocol validation that rejects unsafe URI schemes (e.g. `javascript:`, `data:`).
37 changes: 35 additions & 2 deletions e2e/davinci-app/components/label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,42 @@
import type { ReadOnlyCollector } from '@forgerock/davinci-client/types';

export default function (formEl: HTMLFormElement, collector: ReadOnlyCollector) {
// create paragraph element with text of "Loading ... "
const p = document.createElement('p');
const { richContent } = collector.output;

if (richContent.replacements.length === 0) {
p.innerText = collector.output.content;
formEl?.appendChild(p);
return;
}

// Interpolate the template by splitting on {{key}} and inserting links
const segments = richContent.content.split(/\{\{(\w+)\}\}/);
const replacementMap = new Map(richContent.replacements.map((r) => [r.key, r]));

for (let i = 0; i < segments.length; i++) {
if (i % 2 === 0) {
// Text segment
if (segments[i]) {
p.appendChild(document.createTextNode(segments[i]));
}
} else {
// Replacement key
const replacement = replacementMap.get(segments[i]);
if (replacement?.type === 'link') {
const a = document.createElement('a');
a.href = replacement.href;
a.textContent = replacement.value;
if (replacement.target) {
a.target = replacement.target;
if (replacement.target === '_blank') {
a.rel = 'noopener noreferrer';
}
}
p.appendChild(a);
}
}
}

p.innerText = collector.output.label;
formEl?.appendChild(p);
}
108 changes: 108 additions & 0 deletions packages/davinci-client/src/lib/collector.richcontent.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
import { describe, expectTypeOf, it } from 'vitest';
import type {
ReadOnlyCollectorBase,
ReadOnlyCollector,
RichContentLink,
ValidatedReplacement,
CollectorRichContent,
ValidateReplacementsResult,
NoValueCollector,
} from './collector.types.js';

describe('Rich Content Types', () => {
describe('RichContentLink', () => {
it('should require key, type, value, and href', () => {
expectTypeOf<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('ValidatedReplacement', () => {
it('should be assignable from RichContentLink', () => {
expectTypeOf<RichContentLink>().toMatchTypeOf<ValidatedReplacement>();
});

it('should be assignable to RichContentLink', () => {
expectTypeOf<ValidatedReplacement>().toMatchTypeOf<RichContentLink>();
});
});

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

describe('ValidateReplacementsResult', () => {
it('should narrow to replacements on ok: true', () => {
const result = {} as ValidateReplacementsResult;
if (result.ok) {
expectTypeOf(result.replacements).toEqualTypeOf<ValidatedReplacement[]>();
}
});

it('should narrow to error on ok: false', () => {
const result = {} as ValidateReplacementsResult;
if (!result.ok) {
expectTypeOf(result.error).toBeString();
}
});
});

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

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

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

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

it('should have content and richContent on output', () => {
type Resolved = NoValueCollector<'ReadOnlyCollector'>;
expectTypeOf<Resolved['output']['content']>().toBeString();
expectTypeOf<Resolved['output']['richContent']>().toEqualTypeOf<CollectorRichContent>();
});
});

describe('ReadOnlyCollector alias', () => {
it('should equal ReadOnlyCollectorBase', () => {
expectTypeOf<ReadOnlyCollector>().toEqualTypeOf<ReadOnlyCollectorBase>();
});
});
});
42 changes: 38 additions & 4 deletions packages/davinci-client/src/lib/collector.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,25 @@ export interface NoValueCollectorBase<T extends NoValueCollectorTypes> {
};
}

export interface RichContentLink {
key: string;
type: 'link';
value: string;
href: string;
target?: '_self' | '_blank';
}

export type ValidatedReplacement = RichContentLink;

export interface CollectorRichContent {
content: string;
replacements: ValidatedReplacement[];
}

export type ValidateReplacementsResult =
| { ok: true; replacements: ValidatedReplacement[] }
| { ok: false; error: string };

export interface QrCodeCollectorBase {
category: 'NoValueCollector';
error: string | null;
Expand All @@ -511,6 +530,21 @@ export interface QrCodeCollectorBase {
};
}

export interface ReadOnlyCollectorBase {
category: 'NoValueCollector';
error: string | null;
type: 'ReadOnlyCollector';
id: string;
name: string;
output: {
key: string;
label: string;
type: string;
content: string;
richContent: CollectorRichContent;
};
}

/**
* Type to help infer the collector based on the collector type
* Used specifically in the returnNoValueCollector wrapper function.
Expand All @@ -520,19 +554,19 @@ export interface QrCodeCollectorBase {
*/
export type InferNoValueCollectorType<T extends NoValueCollectorTypes> =
T extends 'ReadOnlyCollector'
? NoValueCollectorBase<'ReadOnlyCollector'>
? ReadOnlyCollectorBase
: T extends 'QrCodeCollector'
? QrCodeCollectorBase
: NoValueCollectorBase<'NoValueCollector'>;

export type NoValueCollectors =
| NoValueCollectorBase<'NoValueCollector'>
| NoValueCollectorBase<'ReadOnlyCollector'>
| ReadOnlyCollectorBase
| QrCodeCollectorBase;

export type NoValueCollector<T extends NoValueCollectorTypes> = NoValueCollectorBase<T>;
export type NoValueCollector<T extends NoValueCollectorTypes> = InferNoValueCollectorType<T>;

export type ReadOnlyCollector = NoValueCollectorBase<'ReadOnlyCollector'>;
export type ReadOnlyCollector = ReadOnlyCollectorBase;

export type QrCodeCollector = QrCodeCollectorBase;

Expand Down
Loading
Loading