diff --git a/.changeset/curly-wolves-swim.md b/.changeset/curly-wolves-swim.md new file mode 100644 index 0000000000..53d7e636c6 --- /dev/null +++ b/.changeset/curly-wolves-swim.md @@ -0,0 +1,5 @@ +--- +'@forgerock/davinci-client': minor +--- + +Add `ImageCollector` for rendering `IMAGE` form fields from DaVinci Forms. diff --git a/e2e/davinci-app/components/form-image.ts b/e2e/davinci-app/components/form-image.ts new file mode 100644 index 0000000000..b12faf15bb --- /dev/null +++ b/e2e/davinci-app/components/form-image.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 - 2026 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 type { ImageCollector } from '@forgerock/davinci-client/types'; + +export default function (formEl: HTMLFormElement, collector: ImageCollector) { + if (collector.error) { + const errorEl = document.createElement('p'); + errorEl.innerText = `Image error: ${collector.error}`; + formEl.appendChild(errorEl); + return; + } + + const container = document.createElement('div'); + + const img = document.createElement('img'); + img.src = collector.output.imageUrl; + img.alt = collector.output.description; + img.setAttribute('data-testid', 'form-image'); + + if (collector.output.hyperlinkUrl) { + const anchor = document.createElement('a'); + anchor.href = collector.output.hyperlinkUrl; + anchor.appendChild(img); + container.appendChild(anchor); + } else { + container.appendChild(img); + } + + formEl.appendChild(container); +} diff --git a/e2e/davinci-app/main.ts b/e2e/davinci-app/main.ts index d2e72d96e7..4a4ae4cbc3 100644 --- a/e2e/davinci-app/main.ts +++ b/e2e/davinci-app/main.ts @@ -34,6 +34,7 @@ import labelComponent from './components/label.js'; import objectValueComponent from './components/object-value.js'; import fidoComponent from './components/fido.js'; import qrCodeComponent from './components/qr-code.js'; +import formImageComponent from './components/form-image.js'; import agreementComponent from './components/agreement.js'; import pollingComponent from './components/polling.js'; import booleanComponent from './components/boolean.js'; @@ -232,6 +233,8 @@ const urlParams = new URLSearchParams(window.location.search); ); } else if (collector.type === 'QrCodeCollector') { qrCodeComponent(formEl, collector); + } else if (collector.type === 'ImageCollector') { + formImageComponent(formEl, collector); } else if (collector.type === 'AgreementCollector') { agreementComponent(formEl, collector); } else if (collector.type === 'TextCollector') { diff --git a/e2e/davinci-suites/src/form-fields.test.ts b/e2e/davinci-suites/src/form-fields.test.ts index c58d792a2b..355c354f7d 100644 --- a/e2e/davinci-suites/src/form-fields.test.ts +++ b/e2e/davinci-suites/src/form-fields.test.ts @@ -67,6 +67,19 @@ test('Should render form fields', async ({ page }) => { await expect(page.locator('#single-checkbox-field')).not.toBeChecked(); await expect(page.locator('.single-checkbox-field-error')).not.toBeAttached(); + // Image collector: img rendered with correct src and alt (from description) + const formImage = page.getByTestId('form-image'); + await expect(formImage).toBeVisible(); + await expect(formImage).toHaveAttribute( + 'src', + 'https://www.pingidentity.com/content/dam/picr/nav/PingIdentity-ProgressPride.svg', + ); + await expect(formImage).toHaveAttribute('alt', 'Ping Identity logo'); + + // Image wrapped in hyperlink (hyperlinkUrl present) + const formImageAnchor = page.locator('a:has([data-testid="form-image"])'); + await expect(formImageAnchor).toHaveAttribute('href', 'https://www.pingidentity.com'); + await expect(page.getByRole('button', { name: 'Flow Button' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Flow Link' })).toBeVisible(); diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index 85ef2b1930..39d9aed81b 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -181,7 +181,7 @@ export interface CollectorRichContent { } // @public (undocumented) -export type Collectors = FlowCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | ValidatedBooleanCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; +export type Collectors = FlowCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | ValidatedBooleanCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | ImageCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -289,13 +289,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; pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -309,6 +307,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -319,7 +319,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; @@ -328,8 +328,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -345,6 +343,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -361,14 +361,14 @@ export function davinci(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required; +// @public +export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { + // (undocumented) + output: NoValueCollectorBase<'ImageCollector'>['output'] & { + imageUrl: string; + description: string; + hyperlinkUrl?: string; + }; +} + +// @public (undocumented) +export type ImageField = { + type: 'IMAGE'; + key: string; + description: string; + imageUrl: string; + hyperlinkUrl?: string; +}; + // @public (undocumented) export type InferActionCollectorType = T extends 'IdpCollector' ? IdpCollector : T extends 'SubmitCollector' ? SubmitCollector : T extends 'FlowCollector' ? FlowCollector : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; @@ -965,7 +984,7 @@ 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' ? ReadOnlyCollector : T extends 'RichTextCollector' ? RichTextCollector : T extends 'QrCodeCollector' ? QrCodeCollector : 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 : T extends 'ImageCollector' ? ImageCollector : NoValueCollectorBase<'NoValueCollector'>; // @public export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : T extends 'ValidatedPasswordCollector' ? ValidatedPasswordCollector : T extends 'ValidatedBooleanCollector' ? ValidatedBooleanCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; @@ -1137,10 +1156,10 @@ export interface NoValueCollectorBase { } // @public (undocumented) -export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector; +export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector | ImageCollector; // @public -export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; +export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector' | 'ImageCollector'; // @public export interface OAuthDetails { @@ -1522,7 +1541,7 @@ export type ReadOnlyField = { }; // @public (undocumented) -export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField; +export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField | ImageField; // @public (undocumented) export type RedirectField = { 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 7f0ca20f1c..facc34e383 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -181,7 +181,7 @@ export interface CollectorRichContent { } // @public (undocumented) -export type Collectors = FlowCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | ValidatedBooleanCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | UnknownCollector; +export type Collectors = FlowCollector | PasswordCollector | ValidatedPasswordCollector | TextCollector | ValidatedBooleanCollector | SingleSelectCollector | IdpCollector | SubmitCollector | ActionCollector<'ActionCollector'> | SingleValueCollector<'SingleValueCollector'> | MultiSelectCollector | DeviceAuthenticationCollector | DeviceRegistrationCollector | PhoneNumberCollector | PhoneNumberExtensionCollector | ReadOnlyCollector | RichTextCollector | ValidatedTextCollector | ProtectCollector | PollingCollector | FidoRegistrationCollector | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector | ImageCollector | UnknownCollector; // @public export type CollectorValueType = T extends { @@ -289,13 +289,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; pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -309,6 +307,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -319,7 +319,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; @@ -328,8 +328,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -345,6 +343,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; @@ -361,14 +361,14 @@ export function davinci(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "data" | "fulfilledTimeStamp"> & Required(input: { } & Omit<{ requestId: string; data?: unknown; - error?: FetchBaseQueryError | SerializedError | undefined; + error?: SerializedError | FetchBaseQueryError | undefined; endpointName: string; startedTimeStamp: number; fulfilledTimeStamp?: number; }, "error"> & Required; +// @public +export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { + // (undocumented) + output: NoValueCollectorBase<'ImageCollector'>['output'] & { + imageUrl: string; + description: string; + hyperlinkUrl?: string; + }; +} + +// @public (undocumented) +export type ImageField = { + type: 'IMAGE'; + key: string; + description: string; + imageUrl: string; + hyperlinkUrl?: string; +}; + // @public (undocumented) export type InferActionCollectorType = T extends 'IdpCollector' ? IdpCollector : T extends 'SubmitCollector' ? SubmitCollector : T extends 'FlowCollector' ? FlowCollector : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; @@ -962,7 +981,7 @@ 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' ? ReadOnlyCollector : T extends 'RichTextCollector' ? RichTextCollector : T extends 'QrCodeCollector' ? QrCodeCollector : 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 : T extends 'ImageCollector' ? ImageCollector : NoValueCollectorBase<'NoValueCollector'>; // @public export type InferSingleValueCollectorType = T extends 'TextCollector' ? TextCollector : T extends 'SingleSelectCollector' ? SingleSelectCollector : T extends 'ValidatedTextCollector' ? ValidatedTextCollector : T extends 'PasswordCollector' ? PasswordCollector : T extends 'ValidatedPasswordCollector' ? ValidatedPasswordCollector : T extends 'ValidatedBooleanCollector' ? ValidatedBooleanCollector : SingleValueCollectorWithValue<'SingleValueCollector'> | SingleValueCollectorNoValue<'SingleValueCollector'>; @@ -1134,10 +1153,10 @@ export interface NoValueCollectorBase { } // @public (undocumented) -export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector; +export type NoValueCollectors = NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | RichTextCollector | QrCodeCollector | AgreementCollector | ImageCollector; // @public -export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector'; +export type NoValueCollectorTypes = 'ReadOnlyCollector' | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' | 'AgreementCollector' | 'ImageCollector'; // @public export interface OAuthDetails { @@ -1519,7 +1538,7 @@ export type ReadOnlyField = { }; // @public (undocumented) -export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField; +export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField | ImageField; // @public (undocumented) export type RedirectField = { 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 e9576f2b19..8303d6b93e 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -29,6 +29,7 @@ import type { RichTextCollector, QrCodeCollector, AgreementCollector, + ImageCollector, PhoneNumberCollector, PhoneNumberExtensionCollector, ObjectValueCollectorWithObjectValue, @@ -653,6 +654,25 @@ describe('Collector Types', () => { expectTypeOf(tCollector).toEqualTypeOf(); }); + + it('should correctly infer ImageCollector Type', () => { + const tCollector: InferNoValueCollectorType<'ImageCollector'> = { + category: 'NoValueCollector', + type: 'ImageCollector', + name: 'ImageCollector', + id: '1', + error: null, + output: { + key: 'image1', + label: '', + type: 'IMAGE', + imageUrl: 'https://example.com/image.png', + description: 'A hero image', + hyperlinkUrl: 'https://example.com', + }, + }; + expectTypeOf(tCollector).toEqualTypeOf(); + }); }); describe('Rich Content Types', () => { diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index 62b3a52b44..009545f253 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -586,7 +586,8 @@ export type NoValueCollectorTypes = | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' - | 'AgreementCollector'; + | 'AgreementCollector' + | 'ImageCollector'; export interface NoValueCollectorBase { category: 'NoValueCollector'; @@ -636,6 +637,19 @@ export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> }; } +/** + * @interface ImageCollector - Display-only collector for IMAGE fields. Extends the + * generic `NoValueCollectorBase` with the image URL (`src`), an optional + * hyperlink URL (`hyperlink.url`), and an optional `description` (alt text). + */ +export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { + output: NoValueCollectorBase<'ImageCollector'>['output'] & { + imageUrl: string; + description: string; + hyperlinkUrl?: string; + }; +} + /** * @interface ReadOnlyCollector - Display-only collector for plain LABEL fields. * Extends `NoValueCollectorBase` with the plain-text `content` from the field. @@ -691,14 +705,17 @@ export type InferNoValueCollectorType = ? QrCodeCollector : T extends 'AgreementCollector' ? AgreementCollector - : NoValueCollectorBase<'NoValueCollector'>; + : T extends 'ImageCollector' + ? ImageCollector + : NoValueCollectorBase<'NoValueCollector'>; export type NoValueCollectors = | NoValueCollectorBase<'NoValueCollector'> | ReadOnlyCollector | RichTextCollector | QrCodeCollector - | AgreementCollector; + | AgreementCollector + | ImageCollector; export type NoValueCollector = InferNoValueCollectorType; diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index 1d890ac149..eb33410299 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -23,6 +23,7 @@ import { returnObjectValueCollector, returnSingleValueAutoCollector, returnObjectValueAutoCollector, + returnImageCollector, returnQrCodeCollector, returnAgreementCollector, normalizeReplacements, @@ -33,6 +34,7 @@ import type { DaVinciField, DeviceAuthenticationField, DeviceRegistrationField, + ImageField, PasswordField, FidoAuthenticationField, FidoRegistrationField, @@ -1095,6 +1097,109 @@ describe('returnQrCodeCollector', () => { }); }); +describe('returnImageCollector', () => { + // (a) Full payload — all five SDK contract fields present + it('should return a fully-populated ImageCollector when all contract fields are present', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'heroImage', + imageUrl: 'https://cdn.example.com/image.png', + description: 'Alt text', + hyperlinkUrl: 'https://example.com/install', + }; + const result = returnImageCollector(mockField, 1); + expect(result).toEqual({ + category: 'NoValueCollector', + error: null, + type: 'ImageCollector', + id: 'heroImage-1', + name: 'heroImage-1', + output: { + key: 'heroImage-1', + label: '', + type: 'IMAGE', + imageUrl: 'https://cdn.example.com/image.png', + description: 'Alt text', + hyperlinkUrl: 'https://example.com/install', + }, + }); + }); + + // (b) Minimal payload — no hyperlinkUrl + it('should omit hyperlinkUrl when hyperlinkUrl is absent', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + description: 'Alt text', + imageUrl: 'https://example.com/test-image.png', + }; + const result = returnImageCollector(mockField, 0); + expect(result.error).toBeNull(); + expect(result.output.imageUrl).toBe('https://example.com/test-image.png'); + expect(result.output.description).toBe('Alt text'); + expect(result.output.label).toBe(''); + expect(result.output).not.toHaveProperty('hyperlinkUrl'); + }); + + // (c) hyperlinkUrl present — hyperlink emitted; absent — omitted + it('should emit hyperlinkUrl when hyperlinkUrl is set and omit it when absent', () => { + const withHyperlink: ImageField = { + type: 'IMAGE', + key: 'image', + description: 'Alt text', + imageUrl: 'https://example.com/test-image.png', + hyperlinkUrl: 'https://example.com/click-target', + }; + const resultWith = returnImageCollector(withHyperlink, 0); + expect(resultWith.output.hyperlinkUrl).toBe('https://example.com/click-target'); + + const withoutHyperlink: ImageField = { + type: 'IMAGE', + key: 'image', + description: 'Alt text', + imageUrl: 'https://example.com/test-image.png', + }; + const resultWithout = returnImageCollector(withoutHyperlink, 0); + expect(resultWithout.output).not.toHaveProperty('hyperlinkUrl'); + }); + + // (d) description passes through verbatim + it('should pass description through verbatim', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + description: 'Friendly alt text', + imageUrl: 'https://example.com/test-image.png', + }; + const result = returnImageCollector(mockField, 0); + expect(result.output.description).toBe('Friendly alt text'); + }); + + // (e) imageUrl absent — src defaults to empty string + it('should default src to empty string when imageUrl is absent', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + description: 'Alt text', + imageUrl: '', + }; + const result = returnImageCollector(mockField, 0); + expect(result.output.imageUrl).toBe(''); + }); + + // (f) output.label === '' for any IMAGE payload + it('should set output.label to empty string regardless of payload shape', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + description: 'Alt text', + imageUrl: 'https://example.com/test-image.png', + }; + const result = returnImageCollector(mockField, 0); + expect(result.output.label).toBe(''); + }); +}); + describe('returnAgreementCollector', () => { it('should return a valid AgreementCollector with all fields', () => { const mockField: AgreementField = { diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 1215aa094e..dab3977304 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -31,6 +31,7 @@ import type { AutoCollectors, SingleValueAutoCollectorTypes, ObjectValueAutoCollectorTypes, + ImageCollector, QrCodeCollector, ReadOnlyCollector, RichTextCollector, @@ -45,6 +46,7 @@ import type { DeviceRegistrationField, FidoAuthenticationField, FidoRegistrationField, + ImageField, MultiSelectField, PasswordField, PhoneNumberField, @@ -920,7 +922,7 @@ export function returnNoValueCollector< name: `${field.key || field.type}-${idx}`, output: { key: `${field.key || field.type}-${idx}`, - label: field.content, + label: 'content' in field ? field.content : '', type: field.type, }, } as InferNoValueCollectorType; @@ -983,6 +985,36 @@ export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCo }; } +/** + * @function returnImageCollector - Creates an ImageCollector object for displaying IMAGE fields. + * + * Composes on top of `returnNoValueCollector` for `category`, `id`, `name`, and base + * `output.{key, type}`. Overrides `output.label` (IMAGE has no wire `label` property; + * set to empty string — consumers use `output.description` for alt-text). + * + * `hyperlink` is present when the server includes `hyperlinkUrl` on the wire. + * `description` is present when the server includes it on the wire. + * + * @param {ImageField} field - The IMAGE field from the API response. + * @param {number} idx - The index used in the collector `id`/`name`. + * @returns {ImageCollector} The constructed ImageCollector object. + */ +export function returnImageCollector(field: ImageField, idx: number): ImageCollector { + const base = returnNoValueCollector(field, idx, 'ImageCollector'); + + return { + ...base, + error: null, + output: { + ...base.output, + label: '', + imageUrl: field.imageUrl, + description: field.description, + ...(field.hyperlinkUrl ? { hyperlinkUrl: field.hyperlinkUrl } : {}), + }, + }; +} + /** * @function returnAgreementCollector - Creates an AgreementCollector object based on the provided field and index. * @param {AgreementField} field - The field object containing key, label, type, and agreement details. diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 705856082f..5ebdc7d8e6 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -140,6 +140,16 @@ export type QrCodeField = { fallbackText?: string; }; +export type ImageField = { + type: 'IMAGE'; + key: string; + description: string; + imageUrl: string; + + // Optional properties + hyperlinkUrl?: string; +}; + export type AgreementField = { type: 'AGREEMENT'; key: string; @@ -328,7 +338,7 @@ export type ComplexValueFields = | FidoAuthenticationField | PollingField; export type MultiValueFields = MultiSelectField; -export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField; +export type ReadOnlyFields = ReadOnlyField | QrCodeField | AgreementField | ImageField; export type RedirectFields = RedirectField; export type SingleValueFields = | StandardField diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index d1fcc95655..2fe2741637 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -21,6 +21,7 @@ import type { ProtectCollector, QrCodeCollector, AgreementCollector, + ImageCollector, SubmitCollector, TextCollector, ValidatedBooleanCollector, @@ -426,6 +427,35 @@ describe('The node collector reducer', () => { ); }); + it('should throw NoValueCollectors are read-only on update', () => { + const action = { + type: 'node/update', + payload: { + id: 'image-0', + value: 'attempted-update', + }, + }; + const state: ImageCollector[] = [ + { + category: 'NoValueCollector', + error: null, + type: 'ImageCollector', + id: 'image-0', + name: 'image-0', + output: { + key: 'image-0', + label: '', + type: 'IMAGE', + imageUrl: 'https://example.com/test-image.png', + description: 'Alt text', + }, + }, + ]; + expect(() => nodeCollectorReducer(state, action)).toThrowError( + 'NoValueCollectors, like ReadOnlyCollectors, are read-only', + ); + }); + it('should handle QR_CODE field type', () => { const action = { type: 'node/next', @@ -502,6 +532,42 @@ describe('The node collector reducer', () => { } satisfies AgreementCollector, ]); }); + + it('should handle IMAGE field type', () => { + const action = { + type: 'node/next', + payload: { + fields: [ + { + type: 'IMAGE', + key: 'image-field', + imageUrl: 'https://example.com/test-image.png', + description: 'Test image alt text', + hyperlinkUrl: 'https://example.com/click-target', + }, + ], + formData: {}, + }, + }; + const result = nodeCollectorReducer(undefined, action); + expect(result).toEqual([ + { + category: 'NoValueCollector', + error: null, + type: 'ImageCollector', + id: 'image-field-0', + name: 'image-field-0', + output: { + key: 'image-field-0', + label: '', + type: 'IMAGE', + imageUrl: 'https://example.com/test-image.png', + description: 'Test image alt text', + hyperlinkUrl: 'https://example.com/click-target', + }, + } satisfies ImageCollector, + ]); + }); }); describe('The node collector reducer with MultiValueCollector', () => { diff --git a/packages/davinci-client/src/lib/node.reducer.ts b/packages/davinci-client/src/lib/node.reducer.ts index 1dd74a6d53..db621697ba 100644 --- a/packages/davinci-client/src/lib/node.reducer.ts +++ b/packages/davinci-client/src/lib/node.reducer.ts @@ -33,6 +33,7 @@ import { returnFidoAuthenticationCollector, returnQrCodeCollector, returnAgreementCollector, + returnImageCollector, } from './collector.utils.js'; import type { DaVinciField, UnknownField } from './davinci.types.js'; import type { PhoneNumberOutputValue, PhoneNumberExtensionOutputValue } from './collector.types.js'; @@ -93,6 +94,10 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build return returnAgreementCollector(field, idx); } + if (field.type === 'IMAGE') { + return returnImageCollector(field, idx); + } + // *Some* collectors may have default or existing data to display const data = action.payload.formData && 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 cab5ce8e90..6de85146c0 100644 --- a/packages/davinci-client/src/lib/node.types.test-d.ts +++ b/packages/davinci-client/src/lib/node.types.test-d.ts @@ -42,6 +42,7 @@ import { FidoAuthenticationCollector, QrCodeCollector, AgreementCollector, + ImageCollector, } from './collector.types.js'; // ErrorDetail and Links are used as part of the DaVinciError and server._links types respectively @@ -250,6 +251,7 @@ describe('Node Types', () => { | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector + | ImageCollector | UnknownCollector >(); diff --git a/packages/davinci-client/src/lib/node.types.ts b/packages/davinci-client/src/lib/node.types.ts index 657feb5085..cd76d835d4 100644 --- a/packages/davinci-client/src/lib/node.types.ts +++ b/packages/davinci-client/src/lib/node.types.ts @@ -31,6 +31,7 @@ import type { FidoAuthenticationCollector, QrCodeCollector, AgreementCollector, + ImageCollector, PhoneNumberExtensionCollector, } from './collector.types.js'; import type { Links } from './davinci.types.js'; @@ -60,6 +61,7 @@ export type Collectors = | FidoAuthenticationCollector | QrCodeCollector | AgreementCollector + | ImageCollector | UnknownCollector; export interface CollectorErrors {