From 57906554f0ce3e414f93ddca4a9ff3f87408686d Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Fri, 19 Jun 2026 19:06:04 -0700 Subject: [PATCH 1/2] feat(davinci-client): add form image collector --- .changeset/curly-wolves-swim.md | 5 + e2e/davinci-app/components/form-image.ts | 44 ++++ e2e/davinci-app/main.ts | 3 + e2e/davinci-suites/src/form-fields.test.ts | 17 ++ .../api-report/davinci-client.api.md | 107 ++++++-- .../api-report/davinci-client.types.api.md | 107 ++++++-- .../src/lib/collector.types.test-d.ts | 32 +++ .../davinci-client/src/lib/collector.types.ts | 42 +++- .../src/lib/collector.utils.test.ts | 236 ++++++++++++++++++ .../davinci-client/src/lib/collector.utils.ts | 61 ++++- .../davinci-client/src/lib/davinci.types.ts | 63 ++++- .../src/lib/node.reducer.test.ts | 100 ++++++++ .../davinci-client/src/lib/node.reducer.ts | 5 + .../src/lib/node.types.test-d.ts | 2 + packages/davinci-client/src/lib/node.types.ts | 2 + 15 files changed, 783 insertions(+), 43 deletions(-) create mode 100644 .changeset/curly-wolves-swim.md create mode 100644 e2e/davinci-app/components/form-image.ts 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..642125e524 --- /dev/null +++ b/e2e/davinci-app/components/form-image.ts @@ -0,0 +1,44 @@ +/* + * 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.src; + img.alt = collector.output.description ?? ''; + img.setAttribute('data-testid', 'form-image'); + if (collector.output.customAttributes?.id) { + img.id = collector.output.customAttributes.id; + } + if (collector.output.customAttributes?.class) { + img.className = collector.output.customAttributes.class; + } + + if (collector.output.hyperlink) { + const anchor = document.createElement('a'); + anchor.href = collector.output.hyperlink.url; + if (collector.output.hyperlink.openInNewTab) { + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + 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..6ce4587862 100644 --- a/e2e/davinci-suites/src/form-fields.test.ts +++ b/e2e/davinci-suites/src/form-fields.test.ts @@ -67,6 +67,23 @@ 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'); + await expect(formImage).toHaveAttribute('id', 'ping-logo'); + await expect(formImage).toHaveAttribute('class', 'ping-brand'); + + // Image wrapped in hyperlink (enableHyperlink=true, openInNewTab=true) + const formImageAnchor = page.locator('a:has([data-testid="form-image"])'); + await expect(formImageAnchor).toHaveAttribute('href', 'https://www.pingidentity.com'); + await expect(formImageAnchor).toHaveAttribute('target', '_blank'); + await expect(formImageAnchor).toHaveAttribute('rel', 'noopener noreferrer'); + 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..8d75adbfeb 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 (undocumented) export type FlowNode = ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; +// @public (undocumented) +export type FormImageCustomAttributes = { + id?: string; + class?: string; + referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url'; +}; + +// @public +export interface FormImageHyperlink { + // (undocumented) + openInNewTab: boolean; + // (undocumented) + source: FormImageSource; + // (undocumented) + url: string; +} + +// @public +export type FormImageLengthUnit = 'PIXELS' | 'PERCENT'; + +// @public +export type FormImageSource = 'URL' | 'FORM_NODE'; + +// @public (undocumented) +export type FormImageStyles = { + size: 'FIT' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'CUSTOM'; + paddingPreset: 'NONE' | 'COMPACT' | 'COMFORTABLE' | 'CUSTOM'; + padding: { + top: number; + right: number; + bottom: number; + left: number; + }; + maxHeightUnit: FormImageLengthUnit; + maxWidthUnit: FormImageLengthUnit; + alignment?: 'LEFT' | 'CENTER' | 'RIGHT'; + maxHeight?: number; + maxWidth?: number; +}; + // @public export type GetClient = StartNode['client'] | ContinueNode['client'] | ErrorNode['client'] | SuccessNode['client'] | FailureNode['client']; // @public (undocumented) export type IdpCollector = ActionCollectorWithUrl<'IdpCollector'>; +// @public +export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { + // (undocumented) + output: NoValueCollectorBase<'ImageCollector'>['output'] & { + src: string; + imageSource: FormImageSource; + hyperlink?: FormImageHyperlink; + customAttributes?: FormImageCustomAttributes; + styles?: FormImageStyles; + description?: string; + }; +} + +// @public (undocumented) +export type ImageField = { + type: 'IMAGE'; + key: string; + imageSource: FormImageSource; + imageUrl: string; + description?: string; + styles?: FormImageStyles; + enableHyperlink?: boolean; + hyperlinkSource?: FormImageSource; + hyperlinkUrl?: string; + openInNewTab?: boolean; + enableCustomAttributes?: boolean; + customAttributes?: FormImageCustomAttributes; +}; + // @public (undocumented) export type InferActionCollectorType = T extends 'IdpCollector' ? IdpCollector : T extends 'SubmitCollector' ? SubmitCollector : T extends 'FlowCollector' ? FlowCollector : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; @@ -965,7 +1034,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 +1206,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 +1591,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..32e32f5db5 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 (undocumented) export type FlowNode = ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; +// @public (undocumented) +export type FormImageCustomAttributes = { + id?: string; + class?: string; + referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url'; +}; + +// @public +export interface FormImageHyperlink { + // (undocumented) + openInNewTab: boolean; + // (undocumented) + source: FormImageSource; + // (undocumented) + url: string; +} + +// @public +export type FormImageLengthUnit = 'PIXELS' | 'PERCENT'; + +// @public +export type FormImageSource = 'URL' | 'FORM_NODE'; + +// @public (undocumented) +export type FormImageStyles = { + size: 'FIT' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'CUSTOM'; + paddingPreset: 'NONE' | 'COMPACT' | 'COMFORTABLE' | 'CUSTOM'; + padding: { + top: number; + right: number; + bottom: number; + left: number; + }; + maxHeightUnit: FormImageLengthUnit; + maxWidthUnit: FormImageLengthUnit; + alignment?: 'LEFT' | 'CENTER' | 'RIGHT'; + maxHeight?: number; + maxWidth?: number; +}; + // @public export type GetClient = StartNode['client'] | ContinueNode['client'] | ErrorNode['client'] | SuccessNode['client'] | FailureNode['client']; // @public (undocumented) export type IdpCollector = ActionCollectorWithUrl<'IdpCollector'>; +// @public +export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { + // (undocumented) + output: NoValueCollectorBase<'ImageCollector'>['output'] & { + src: string; + imageSource: FormImageSource; + hyperlink?: FormImageHyperlink; + customAttributes?: FormImageCustomAttributes; + styles?: FormImageStyles; + description?: string; + }; +} + +// @public (undocumented) +export type ImageField = { + type: 'IMAGE'; + key: string; + imageSource: FormImageSource; + imageUrl: string; + description?: string; + styles?: FormImageStyles; + enableHyperlink?: boolean; + hyperlinkSource?: FormImageSource; + hyperlinkUrl?: string; + openInNewTab?: boolean; + enableCustomAttributes?: boolean; + customAttributes?: FormImageCustomAttributes; +}; + // @public (undocumented) export type InferActionCollectorType = T extends 'IdpCollector' ? IdpCollector : T extends 'SubmitCollector' ? SubmitCollector : T extends 'FlowCollector' ? FlowCollector : ActionCollectorWithUrl<'ActionCollector'> | ActionCollectorNoUrl<'ActionCollector'>; @@ -962,7 +1031,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 +1203,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 +1588,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..e12bf32c53 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,37 @@ 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', + src: 'https://example.com/image.png', + imageSource: 'URL', + hyperlink: { url: 'https://example.com', openInNewTab: true, source: 'URL' }, + customAttributes: { id: 'img1', class: 'hero' }, + styles: { + size: 'CUSTOM', + paddingPreset: 'CUSTOM', + padding: { top: 4, right: 4, bottom: 4, left: 4 }, + maxHeightUnit: 'PIXELS', + maxWidthUnit: 'PIXELS', + alignment: 'CENTER', + maxHeight: 200, + maxWidth: 400, + }, + description: 'A hero image', + }, + }; + 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..0c4065d42c 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -8,6 +8,9 @@ import type { FidoAuthenticationOptions, FidoRegistrationOptions, + FormImageCustomAttributes, + FormImageSource, + FormImageStyles, PasswordPolicy, } from './davinci.types.js'; @@ -586,7 +589,8 @@ export type NoValueCollectorTypes = | 'RichTextCollector' | 'NoValueCollector' | 'QrCodeCollector' - | 'AgreementCollector'; + | 'AgreementCollector' + | 'ImageCollector'; export interface NoValueCollectorBase { category: 'NoValueCollector'; @@ -636,6 +640,35 @@ export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> }; } +/** + * SDK-side normalized hyperlink shape exposed on `ImageCollector.output.hyperlink`. + * Distinct from the wire-level `enableHyperlink` / `hyperlinkUrl` / `hyperlinkSource` / + * `openInNewTab` fields on `ImageField`: the factory composes those into this single + * present-or-absent object so consumers can branch on `if (output.hyperlink) { ... }`. + */ +export interface FormImageHyperlink { + url: string; + openInNewTab: boolean; + source: FormImageSource; +} + +/** + * @interface ImageCollector - Display-only collector for IMAGE fields. Extends the + * generic `NoValueCollectorBase` with the image source-of-truth flag (`imageSource`), + * the URL string (`src`, empty when the URL is runtime-injected via `FORM_NODE`), and + * the optional `hyperlink`, `customAttributes`, `styles`, and `description` payload. + */ +export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { + output: NoValueCollectorBase<'ImageCollector'>['output'] & { + src: string; + imageSource: FormImageSource; + hyperlink?: FormImageHyperlink; + customAttributes?: FormImageCustomAttributes; + styles?: FormImageStyles; + description?: string; + }; +} + /** * @interface ReadOnlyCollector - Display-only collector for plain LABEL fields. * Extends `NoValueCollectorBase` with the plain-text `content` from the field. @@ -691,14 +724,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..d5038e5652 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,9 @@ import type { DaVinciField, DeviceAuthenticationField, DeviceRegistrationField, + FormImageCustomAttributes, + FormImageStyles, + ImageField, PasswordField, FidoAuthenticationField, FidoRegistrationField, @@ -1095,6 +1099,238 @@ describe('returnQrCodeCollector', () => { }); }); +describe('returnImageCollector', () => { + // (a) Full payload (verified-live URL-source happy path) + it('should return a fully-populated ImageCollector for the verified-live URL-source happy path', () => { + const styles: FormImageStyles = { + size: 'FIT', + paddingPreset: 'COMFORTABLE', + padding: { top: 20, right: 20, bottom: 20, left: 20 }, + maxHeightUnit: 'PIXELS', + maxWidthUnit: 'PIXELS', + alignment: 'CENTER', + }; + const customAttributes: FormImageCustomAttributes = { + id: 'test-image-id', + class: 'test-image-class', + referrerPolicy: 'no-referrer', + }; + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + description: 'Test image alt text', + styles, + enableHyperlink: true, + hyperlinkSource: 'URL', + hyperlinkUrl: 'https://example.com/click-target', + openInNewTab: true, + enableCustomAttributes: true, + customAttributes, + }; + const result = returnImageCollector(mockField, 1); + expect(result).toEqual({ + category: 'NoValueCollector', + error: null, + type: 'ImageCollector', + id: 'image-1', + name: 'image-1', + output: { + key: 'image-1', + label: '', + type: 'IMAGE', + src: 'https://example.com/test-image.png', + imageSource: 'URL', + description: 'Test image alt text', + styles, + hyperlink: { + url: 'https://example.com/click-target', + openInNewTab: true, + source: 'URL', + }, + customAttributes, + }, + }); + }); + + // (b) Minimal payload — only imageSource: 'URL' and imageUrl set + it('should omit hyperlink, customAttributes, styles, and description on a minimal URL payload', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + }; + const result = returnImageCollector(mockField, 0); + expect(result.error).toBeNull(); + expect(result.output.src).toBe('https://example.com/test-image.png'); + expect(result.output.imageSource).toBe('URL'); + expect(result.output.label).toBe(''); + expect(result.output).not.toHaveProperty('hyperlink'); + expect(result.output).not.toHaveProperty('customAttributes'); + expect(result.output).not.toHaveProperty('styles'); + expect(result.output).not.toHaveProperty('description'); + }); + + // (c1) imageSource: 'FORM_NODE' with imageUrl populated (typical wire case) + it('should expose stale imageUrl as src and FORM_NODE as imageSource when source is FORM_NODE with prior URL', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'FORM_NODE', + imageUrl: 'https://example.com/stale-image.png', + }; + const result = returnImageCollector(mockField, 0); + expect(result.error).toBeNull(); + expect(result.output.src).toBe('https://example.com/stale-image.png'); + expect(result.output.imageSource).toBe('FORM_NODE'); + }); + + // (c2) imageSource: 'FORM_NODE' with no imageUrl + it('should default src to empty string and report no error when source is FORM_NODE without URL', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'FORM_NODE', + imageUrl: '', + }; + const result = returnImageCollector(mockField, 0); + expect(result.error).toBeNull(); + expect(result.output.src).toBe(''); + expect(result.output.imageSource).toBe('FORM_NODE'); + }); + + // (d) Missing imageUrl when imageSource === 'URL' + it('should set the IMAGE-specific error when imageUrl is missing for URL source', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: '', + }; + const result = returnImageCollector(mockField, 0); + expect(result.error).toBe('imageUrl is required when imageSource is URL'); + expect(result.output.src).toBe(''); + }); + + // (e) Description present and absent — both asserted in one test + it('should pass description through verbatim when present and omit it when absent', () => { + const fieldWithDescription: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + description: 'Friendly alt text', + }; + const resultWith = returnImageCollector(fieldWithDescription, 0); + expect(resultWith.output.description).toBe('Friendly alt text'); + + const fieldWithoutDescription: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + }; + const resultWithout = returnImageCollector(fieldWithoutDescription, 0); + expect(resultWithout.output).not.toHaveProperty('description'); + }); + + // (f) customAttributes.referrerPolicy stripped (server-stripped-null case) + it('should preserve a customAttributes object that has no referrerPolicy', () => { + const customAttributes: FormImageCustomAttributes = { + id: 'test-image-id', + class: 'test-image-class', + }; + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + enableCustomAttributes: true, + customAttributes, + }; + const result = returnImageCollector(mockField, 0); + expect(result.output.customAttributes).toEqual(customAttributes); + expect(result.output.customAttributes).not.toHaveProperty('referrerPolicy'); + }); + + // (g) styles.padding materialized from preset (paddingPreset: 'COMFORTABLE') + it('should pass styles.padding through as the four-sided integer object materialized from the preset', () => { + const styles: FormImageStyles = { + size: 'FIT', + paddingPreset: 'COMFORTABLE', + padding: { top: 20, right: 20, bottom: 20, left: 20 }, + maxHeightUnit: 'PIXELS', + maxWidthUnit: 'PIXELS', + }; + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + styles, + }; + const result = returnImageCollector(mockField, 0); + expect(result.output.styles?.padding).toEqual({ top: 20, right: 20, bottom: 20, left: 20 }); + expect(result.output.styles?.paddingPreset).toBe('COMFORTABLE'); + }); + + // (h) enableHyperlink: false — defensive + it('should omit hyperlink when enableHyperlink is explicitly false', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + enableHyperlink: false, + hyperlinkSource: 'URL', + hyperlinkUrl: 'https://example.com/click-target', + openInNewTab: true, + }; + const result = returnImageCollector(mockField, 0); + expect(result.output).not.toHaveProperty('hyperlink'); + }); + + // (i) enableCustomAttributes: false — defensive (never empty object) + it('should omit customAttributes when enableCustomAttributes is explicitly false', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + enableCustomAttributes: false, + customAttributes: { id: 'should-not-leak' }, + }; + const result = returnImageCollector(mockField, 0); + expect(result.output).not.toHaveProperty('customAttributes'); + }); + + // (j) styles absent on payload + it('should omit styles when the payload has no styles property', () => { + const mockField: ImageField = { + type: 'IMAGE', + key: 'image', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + }; + const result = returnImageCollector(mockField, 0); + expect(result.output).not.toHaveProperty('styles'); + }); + + // (k) 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', + imageSource: 'URL', + 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..7b972030a8 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,63 @@ 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 `error` and `output.label`: + * + * - `error`: `returnNoValueCollector` checks `'content' in field` and sets a missing-content + * error string, but `ImageField` has no `content` property — it has `imageUrl`. The factory + * replaces that derivation with an IMAGE-specific check: `error` is non-null only when + * `imageSource === 'URL'` and `imageUrl` is missing/empty (the FORM_NODE source is valid + * even with no static URL because the URL is runtime-injected). + * - `output.label`: IMAGE has no wire `label` property; set to empty string. Consumers should + * use `output.description` for alt-text purposes. + * + * Optional `hyperlink`, `customAttributes`, `styles`, and `description` are emitted via + * defensive conditional spread so absent properties do not appear on `output` at all. + * `output.customAttributes` is set only when both `enableCustomAttributes === true` AND + * `field.customAttributes` is present on the wire — never `{}`. + * + * @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'); + + const error = + field.imageSource === 'URL' && !field.imageUrl + ? 'imageUrl is required when imageSource is URL' + : null; + + return { + ...base, + error, + output: { + ...base.output, + label: '', + src: field.imageUrl || '', + imageSource: field.imageSource, + ...(field.enableHyperlink + ? { + hyperlink: { + url: field.hyperlinkUrl ?? '', + openInNewTab: field.openInNewTab ?? true, + source: field.hyperlinkSource ?? 'URL', + }, + } + : {}), + ...(field.enableCustomAttributes && field.customAttributes + ? { customAttributes: field.customAttributes } + : {}), + ...(field.styles ? { styles: field.styles } : {}), + ...(field.description !== undefined ? { description: field.description } : {}), + }, + }; +} + /** * @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..2dc9eddc4d 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -140,6 +140,67 @@ export type QrCodeField = { fallbackText?: string; }; +/** + * Origin of an IMAGE field's URL value. `'URL'` means the URL is embedded in + * the form definition; `'FORM_NODE'` means the URL is injected at runtime by + * the DaVinci Show Form node connector. Used for both `imageSource` (the + * image itself) and `hyperlinkSource` (the click-through URL when hyperlink + * is enabled). + */ +export type FormImageSource = 'URL' | 'FORM_NODE'; + +/** + * Unit for `FormImageStyles.maxHeight` / `maxWidth`. Used twice in + * `FormImageStyles` (`maxHeightUnit` and `maxWidthUnit`); extracted to keep + * a single source of truth. + */ +export type FormImageLengthUnit = 'PIXELS' | 'PERCENT'; + +export type FormImageStyles = { + size: 'FIT' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'CUSTOM'; + paddingPreset: 'NONE' | 'COMPACT' | 'COMFORTABLE' | 'CUSTOM'; + padding: { top: number; right: number; bottom: number; left: number }; + maxHeightUnit: FormImageLengthUnit; + maxWidthUnit: FormImageLengthUnit; + + // Optional properties + alignment?: 'LEFT' | 'CENTER' | 'RIGHT'; + maxHeight?: number; + maxWidth?: number; +}; + +export type FormImageCustomAttributes = { + // Optional properties + id?: string; + class?: string; + referrerPolicy?: + | 'no-referrer' + | 'no-referrer-when-downgrade' + | 'origin' + | 'origin-when-cross-origin' + | 'same-origin' + | 'strict-origin' + | 'strict-origin-when-cross-origin' + | 'unsafe-url'; +}; + +export type ImageField = { + type: 'IMAGE'; + key: string; + imageSource: FormImageSource; + imageUrl: string; + + // Optional properties + description?: string; + styles?: FormImageStyles; + enableHyperlink?: boolean; + hyperlinkSource?: FormImageSource; + hyperlinkUrl?: string; + openInNewTab?: boolean; + enableCustomAttributes?: boolean; + customAttributes?: FormImageCustomAttributes; +}; + export type AgreementField = { type: 'AGREEMENT'; key: string; @@ -328,7 +389,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..ee99a36d9e 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', + src: 'https://example.com/test-image.png', + imageSource: 'URL', + }, + }, + ]; + 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,76 @@ 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', + imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + description: 'Test image alt text', + styles: { + size: 'FIT', + paddingPreset: 'COMFORTABLE', + padding: { top: 20, right: 20, bottom: 20, left: 20 }, + maxHeightUnit: 'PIXELS', + maxWidthUnit: 'PIXELS', + alignment: 'CENTER', + }, + enableHyperlink: true, + hyperlinkSource: 'URL', + hyperlinkUrl: 'https://example.com/click-target', + openInNewTab: true, + enableCustomAttributes: true, + customAttributes: { + id: 'test-image-id', + class: 'test-image-class', + }, + }, + ], + 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', + src: 'https://example.com/test-image.png', + imageSource: 'URL', + description: 'Test image alt text', + styles: { + size: 'FIT', + paddingPreset: 'COMFORTABLE', + padding: { top: 20, right: 20, bottom: 20, left: 20 }, + maxHeightUnit: 'PIXELS', + maxWidthUnit: 'PIXELS', + alignment: 'CENTER', + }, + hyperlink: { + url: 'https://example.com/click-target', + openInNewTab: true, + source: 'URL', + }, + customAttributes: { + id: 'test-image-id', + class: 'test-image-class', + }, + }, + } 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 { From 8d48854aef8aea8948501c06b31ed4b38ee17a6c Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Fri, 19 Jun 2026 19:26:57 -0700 Subject: [PATCH 2/2] fix(davinci-client): update image collector properties --- e2e/davinci-app/components/form-image.ts | 18 +- e2e/davinci-suites/src/form-fields.test.ts | 6 +- .../api-report/davinci-client.api.md | 58 +---- .../api-report/davinci-client.types.api.md | 58 +---- .../src/lib/collector.types.test-d.ts | 16 +- .../davinci-client/src/lib/collector.types.ts | 29 +-- .../src/lib/collector.utils.test.ts | 211 ++++-------------- .../davinci-client/src/lib/collector.utils.ts | 43 +--- .../davinci-client/src/lib/davinci.types.ts | 53 +---- .../src/lib/node.reducer.test.ts | 42 +--- 10 files changed, 73 insertions(+), 461 deletions(-) diff --git a/e2e/davinci-app/components/form-image.ts b/e2e/davinci-app/components/form-image.ts index 642125e524..b12faf15bb 100644 --- a/e2e/davinci-app/components/form-image.ts +++ b/e2e/davinci-app/components/form-image.ts @@ -17,23 +17,13 @@ export default function (formEl: HTMLFormElement, collector: ImageCollector) { const container = document.createElement('div'); const img = document.createElement('img'); - img.src = collector.output.src; - img.alt = collector.output.description ?? ''; + img.src = collector.output.imageUrl; + img.alt = collector.output.description; img.setAttribute('data-testid', 'form-image'); - if (collector.output.customAttributes?.id) { - img.id = collector.output.customAttributes.id; - } - if (collector.output.customAttributes?.class) { - img.className = collector.output.customAttributes.class; - } - if (collector.output.hyperlink) { + if (collector.output.hyperlinkUrl) { const anchor = document.createElement('a'); - anchor.href = collector.output.hyperlink.url; - if (collector.output.hyperlink.openInNewTab) { - anchor.target = '_blank'; - anchor.rel = 'noopener noreferrer'; - } + anchor.href = collector.output.hyperlinkUrl; anchor.appendChild(img); container.appendChild(anchor); } else { diff --git a/e2e/davinci-suites/src/form-fields.test.ts b/e2e/davinci-suites/src/form-fields.test.ts index 6ce4587862..355c354f7d 100644 --- a/e2e/davinci-suites/src/form-fields.test.ts +++ b/e2e/davinci-suites/src/form-fields.test.ts @@ -75,14 +75,10 @@ test('Should render form fields', async ({ page }) => { 'https://www.pingidentity.com/content/dam/picr/nav/PingIdentity-ProgressPride.svg', ); await expect(formImage).toHaveAttribute('alt', 'Ping Identity logo'); - await expect(formImage).toHaveAttribute('id', 'ping-logo'); - await expect(formImage).toHaveAttribute('class', 'ping-brand'); - // Image wrapped in hyperlink (enableHyperlink=true, openInNewTab=true) + // 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(formImageAnchor).toHaveAttribute('target', '_blank'); - await expect(formImageAnchor).toHaveAttribute('rel', 'noopener noreferrer'); 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 8d75adbfeb..39d9aed81b 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -949,46 +949,6 @@ export type FlowCollector = ActionCollectorNoUrl<'FlowCollector'>; // @public (undocumented) export type FlowNode = ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; -// @public (undocumented) -export type FormImageCustomAttributes = { - id?: string; - class?: string; - referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url'; -}; - -// @public -export interface FormImageHyperlink { - // (undocumented) - openInNewTab: boolean; - // (undocumented) - source: FormImageSource; - // (undocumented) - url: string; -} - -// @public -export type FormImageLengthUnit = 'PIXELS' | 'PERCENT'; - -// @public -export type FormImageSource = 'URL' | 'FORM_NODE'; - -// @public (undocumented) -export type FormImageStyles = { - size: 'FIT' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'CUSTOM'; - paddingPreset: 'NONE' | 'COMPACT' | 'COMFORTABLE' | 'CUSTOM'; - padding: { - top: number; - right: number; - bottom: number; - left: number; - }; - maxHeightUnit: FormImageLengthUnit; - maxWidthUnit: FormImageLengthUnit; - alignment?: 'LEFT' | 'CENTER' | 'RIGHT'; - maxHeight?: number; - maxWidth?: number; -}; - // @public export type GetClient = StartNode['client'] | ContinueNode['client'] | ErrorNode['client'] | SuccessNode['client'] | FailureNode['client']; @@ -999,12 +959,9 @@ export type IdpCollector = ActionCollectorWithUrl<'IdpCollector'>; export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { // (undocumented) output: NoValueCollectorBase<'ImageCollector'>['output'] & { - src: string; - imageSource: FormImageSource; - hyperlink?: FormImageHyperlink; - customAttributes?: FormImageCustomAttributes; - styles?: FormImageStyles; - description?: string; + imageUrl: string; + description: string; + hyperlinkUrl?: string; }; } @@ -1012,16 +969,9 @@ export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { export type ImageField = { type: 'IMAGE'; key: string; - imageSource: FormImageSource; + description: string; imageUrl: string; - description?: string; - styles?: FormImageStyles; - enableHyperlink?: boolean; - hyperlinkSource?: FormImageSource; hyperlinkUrl?: string; - openInNewTab?: boolean; - enableCustomAttributes?: boolean; - customAttributes?: FormImageCustomAttributes; }; // @public (undocumented) 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 32e32f5db5..facc34e383 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -946,46 +946,6 @@ export type FlowCollector = ActionCollectorNoUrl<'FlowCollector'>; // @public (undocumented) export type FlowNode = ContinueNode | ErrorNode | StartNode | SuccessNode | FailureNode; -// @public (undocumented) -export type FormImageCustomAttributes = { - id?: string; - class?: string; - referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url'; -}; - -// @public -export interface FormImageHyperlink { - // (undocumented) - openInNewTab: boolean; - // (undocumented) - source: FormImageSource; - // (undocumented) - url: string; -} - -// @public -export type FormImageLengthUnit = 'PIXELS' | 'PERCENT'; - -// @public -export type FormImageSource = 'URL' | 'FORM_NODE'; - -// @public (undocumented) -export type FormImageStyles = { - size: 'FIT' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'CUSTOM'; - paddingPreset: 'NONE' | 'COMPACT' | 'COMFORTABLE' | 'CUSTOM'; - padding: { - top: number; - right: number; - bottom: number; - left: number; - }; - maxHeightUnit: FormImageLengthUnit; - maxWidthUnit: FormImageLengthUnit; - alignment?: 'LEFT' | 'CENTER' | 'RIGHT'; - maxHeight?: number; - maxWidth?: number; -}; - // @public export type GetClient = StartNode['client'] | ContinueNode['client'] | ErrorNode['client'] | SuccessNode['client'] | FailureNode['client']; @@ -996,12 +956,9 @@ export type IdpCollector = ActionCollectorWithUrl<'IdpCollector'>; export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { // (undocumented) output: NoValueCollectorBase<'ImageCollector'>['output'] & { - src: string; - imageSource: FormImageSource; - hyperlink?: FormImageHyperlink; - customAttributes?: FormImageCustomAttributes; - styles?: FormImageStyles; - description?: string; + imageUrl: string; + description: string; + hyperlinkUrl?: string; }; } @@ -1009,16 +966,9 @@ export interface ImageCollector extends NoValueCollectorBase<'ImageCollector'> { export type ImageField = { type: 'IMAGE'; key: string; - imageSource: FormImageSource; + description: string; imageUrl: string; - description?: string; - styles?: FormImageStyles; - enableHyperlink?: boolean; - hyperlinkSource?: FormImageSource; hyperlinkUrl?: string; - openInNewTab?: boolean; - enableCustomAttributes?: boolean; - customAttributes?: FormImageCustomAttributes; }; // @public (undocumented) 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 e12bf32c53..8303d6b93e 100644 --- a/packages/davinci-client/src/lib/collector.types.test-d.ts +++ b/packages/davinci-client/src/lib/collector.types.test-d.ts @@ -666,21 +666,9 @@ describe('Collector Types', () => { key: 'image1', label: '', type: 'IMAGE', - src: 'https://example.com/image.png', - imageSource: 'URL', - hyperlink: { url: 'https://example.com', openInNewTab: true, source: 'URL' }, - customAttributes: { id: 'img1', class: 'hero' }, - styles: { - size: 'CUSTOM', - paddingPreset: 'CUSTOM', - padding: { top: 4, right: 4, bottom: 4, left: 4 }, - maxHeightUnit: 'PIXELS', - maxWidthUnit: 'PIXELS', - alignment: 'CENTER', - maxHeight: 200, - maxWidth: 400, - }, + imageUrl: 'https://example.com/image.png', description: 'A hero image', + hyperlinkUrl: 'https://example.com', }, }; expectTypeOf(tCollector).toEqualTypeOf(); diff --git a/packages/davinci-client/src/lib/collector.types.ts b/packages/davinci-client/src/lib/collector.types.ts index 0c4065d42c..009545f253 100644 --- a/packages/davinci-client/src/lib/collector.types.ts +++ b/packages/davinci-client/src/lib/collector.types.ts @@ -8,9 +8,6 @@ import type { FidoAuthenticationOptions, FidoRegistrationOptions, - FormImageCustomAttributes, - FormImageSource, - FormImageStyles, PasswordPolicy, } from './davinci.types.js'; @@ -640,32 +637,16 @@ export interface QrCodeCollector extends NoValueCollectorBase<'QrCodeCollector'> }; } -/** - * SDK-side normalized hyperlink shape exposed on `ImageCollector.output.hyperlink`. - * Distinct from the wire-level `enableHyperlink` / `hyperlinkUrl` / `hyperlinkSource` / - * `openInNewTab` fields on `ImageField`: the factory composes those into this single - * present-or-absent object so consumers can branch on `if (output.hyperlink) { ... }`. - */ -export interface FormImageHyperlink { - url: string; - openInNewTab: boolean; - source: FormImageSource; -} - /** * @interface ImageCollector - Display-only collector for IMAGE fields. Extends the - * generic `NoValueCollectorBase` with the image source-of-truth flag (`imageSource`), - * the URL string (`src`, empty when the URL is runtime-injected via `FORM_NODE`), and - * the optional `hyperlink`, `customAttributes`, `styles`, and `description` payload. + * 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'] & { - src: string; - imageSource: FormImageSource; - hyperlink?: FormImageHyperlink; - customAttributes?: FormImageCustomAttributes; - styles?: FormImageStyles; - description?: string; + imageUrl: string; + description: string; + hyperlinkUrl?: string; }; } diff --git a/packages/davinci-client/src/lib/collector.utils.test.ts b/packages/davinci-client/src/lib/collector.utils.test.ts index d5038e5652..eb33410299 100644 --- a/packages/davinci-client/src/lib/collector.utils.test.ts +++ b/packages/davinci-client/src/lib/collector.utils.test.ts @@ -34,8 +34,6 @@ import type { DaVinciField, DeviceAuthenticationField, DeviceRegistrationField, - FormImageCustomAttributes, - FormImageStyles, ImageField, PasswordField, FidoAuthenticationField, @@ -1100,230 +1098,101 @@ describe('returnQrCodeCollector', () => { }); describe('returnImageCollector', () => { - // (a) Full payload (verified-live URL-source happy path) - it('should return a fully-populated ImageCollector for the verified-live URL-source happy path', () => { - const styles: FormImageStyles = { - size: 'FIT', - paddingPreset: 'COMFORTABLE', - padding: { top: 20, right: 20, bottom: 20, left: 20 }, - maxHeightUnit: 'PIXELS', - maxWidthUnit: 'PIXELS', - alignment: 'CENTER', - }; - const customAttributes: FormImageCustomAttributes = { - id: 'test-image-id', - class: 'test-image-class', - referrerPolicy: 'no-referrer', - }; + // (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: 'image', - imageSource: 'URL', - imageUrl: 'https://example.com/test-image.png', - description: 'Test image alt text', - styles, - enableHyperlink: true, - hyperlinkSource: 'URL', - hyperlinkUrl: 'https://example.com/click-target', - openInNewTab: true, - enableCustomAttributes: true, - customAttributes, + 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: 'image-1', - name: 'image-1', + id: 'heroImage-1', + name: 'heroImage-1', output: { - key: 'image-1', + key: 'heroImage-1', label: '', type: 'IMAGE', - src: 'https://example.com/test-image.png', - imageSource: 'URL', - description: 'Test image alt text', - styles, - hyperlink: { - url: 'https://example.com/click-target', - openInNewTab: true, - source: 'URL', - }, - customAttributes, + imageUrl: 'https://cdn.example.com/image.png', + description: 'Alt text', + hyperlinkUrl: 'https://example.com/install', }, }); }); - // (b) Minimal payload — only imageSource: 'URL' and imageUrl set - it('should omit hyperlink, customAttributes, styles, and description on a minimal URL payload', () => { + // (b) Minimal payload — no hyperlinkUrl + it('should omit hyperlinkUrl when hyperlinkUrl is absent', () => { const mockField: ImageField = { type: 'IMAGE', key: 'image', - imageSource: 'URL', + description: 'Alt text', imageUrl: 'https://example.com/test-image.png', }; const result = returnImageCollector(mockField, 0); expect(result.error).toBeNull(); - expect(result.output.src).toBe('https://example.com/test-image.png'); - expect(result.output.imageSource).toBe('URL'); + 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('hyperlink'); - expect(result.output).not.toHaveProperty('customAttributes'); - expect(result.output).not.toHaveProperty('styles'); - expect(result.output).not.toHaveProperty('description'); - }); - - // (c1) imageSource: 'FORM_NODE' with imageUrl populated (typical wire case) - it('should expose stale imageUrl as src and FORM_NODE as imageSource when source is FORM_NODE with prior URL', () => { - const mockField: ImageField = { - type: 'IMAGE', - key: 'image', - imageSource: 'FORM_NODE', - imageUrl: 'https://example.com/stale-image.png', - }; - const result = returnImageCollector(mockField, 0); - expect(result.error).toBeNull(); - expect(result.output.src).toBe('https://example.com/stale-image.png'); - expect(result.output.imageSource).toBe('FORM_NODE'); - }); - - // (c2) imageSource: 'FORM_NODE' with no imageUrl - it('should default src to empty string and report no error when source is FORM_NODE without URL', () => { - const mockField: ImageField = { - type: 'IMAGE', - key: 'image', - imageSource: 'FORM_NODE', - imageUrl: '', - }; - const result = returnImageCollector(mockField, 0); - expect(result.error).toBeNull(); - expect(result.output.src).toBe(''); - expect(result.output.imageSource).toBe('FORM_NODE'); - }); - - // (d) Missing imageUrl when imageSource === 'URL' - it('should set the IMAGE-specific error when imageUrl is missing for URL source', () => { - const mockField: ImageField = { - type: 'IMAGE', - key: 'image', - imageSource: 'URL', - imageUrl: '', - }; - const result = returnImageCollector(mockField, 0); - expect(result.error).toBe('imageUrl is required when imageSource is URL'); - expect(result.output.src).toBe(''); + expect(result.output).not.toHaveProperty('hyperlinkUrl'); }); - // (e) Description present and absent — both asserted in one test - it('should pass description through verbatim when present and omit it when absent', () => { - const fieldWithDescription: ImageField = { + // (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', - imageSource: 'URL', + description: 'Alt text', imageUrl: 'https://example.com/test-image.png', - description: 'Friendly alt text', - }; - const resultWith = returnImageCollector(fieldWithDescription, 0); - expect(resultWith.output.description).toBe('Friendly alt text'); - - const fieldWithoutDescription: ImageField = { - type: 'IMAGE', - key: 'image', - imageSource: 'URL', - imageUrl: 'https://example.com/test-image.png', - }; - const resultWithout = returnImageCollector(fieldWithoutDescription, 0); - expect(resultWithout.output).not.toHaveProperty('description'); - }); - - // (f) customAttributes.referrerPolicy stripped (server-stripped-null case) - it('should preserve a customAttributes object that has no referrerPolicy', () => { - const customAttributes: FormImageCustomAttributes = { - id: 'test-image-id', - class: 'test-image-class', - }; - const mockField: ImageField = { - type: 'IMAGE', - key: 'image', - imageSource: 'URL', - imageUrl: 'https://example.com/test-image.png', - enableCustomAttributes: true, - customAttributes, - }; - const result = returnImageCollector(mockField, 0); - expect(result.output.customAttributes).toEqual(customAttributes); - expect(result.output.customAttributes).not.toHaveProperty('referrerPolicy'); - }); - - // (g) styles.padding materialized from preset (paddingPreset: 'COMFORTABLE') - it('should pass styles.padding through as the four-sided integer object materialized from the preset', () => { - const styles: FormImageStyles = { - size: 'FIT', - paddingPreset: 'COMFORTABLE', - padding: { top: 20, right: 20, bottom: 20, left: 20 }, - maxHeightUnit: 'PIXELS', - maxWidthUnit: 'PIXELS', - }; - const mockField: ImageField = { - type: 'IMAGE', - key: 'image', - imageSource: 'URL', - imageUrl: 'https://example.com/test-image.png', - styles, + hyperlinkUrl: 'https://example.com/click-target', }; - const result = returnImageCollector(mockField, 0); - expect(result.output.styles?.padding).toEqual({ top: 20, right: 20, bottom: 20, left: 20 }); - expect(result.output.styles?.paddingPreset).toBe('COMFORTABLE'); - }); + const resultWith = returnImageCollector(withHyperlink, 0); + expect(resultWith.output.hyperlinkUrl).toBe('https://example.com/click-target'); - // (h) enableHyperlink: false — defensive - it('should omit hyperlink when enableHyperlink is explicitly false', () => { - const mockField: ImageField = { + const withoutHyperlink: ImageField = { type: 'IMAGE', key: 'image', - imageSource: 'URL', + description: 'Alt text', imageUrl: 'https://example.com/test-image.png', - enableHyperlink: false, - hyperlinkSource: 'URL', - hyperlinkUrl: 'https://example.com/click-target', - openInNewTab: true, }; - const result = returnImageCollector(mockField, 0); - expect(result.output).not.toHaveProperty('hyperlink'); + const resultWithout = returnImageCollector(withoutHyperlink, 0); + expect(resultWithout.output).not.toHaveProperty('hyperlinkUrl'); }); - // (i) enableCustomAttributes: false — defensive (never empty object) - it('should omit customAttributes when enableCustomAttributes is explicitly false', () => { + // (d) description passes through verbatim + it('should pass description through verbatim', () => { const mockField: ImageField = { type: 'IMAGE', key: 'image', - imageSource: 'URL', + description: 'Friendly alt text', imageUrl: 'https://example.com/test-image.png', - enableCustomAttributes: false, - customAttributes: { id: 'should-not-leak' }, }; const result = returnImageCollector(mockField, 0); - expect(result.output).not.toHaveProperty('customAttributes'); + expect(result.output.description).toBe('Friendly alt text'); }); - // (j) styles absent on payload - it('should omit styles when the payload has no styles property', () => { + // (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', - imageSource: 'URL', - imageUrl: 'https://example.com/test-image.png', + description: 'Alt text', + imageUrl: '', }; const result = returnImageCollector(mockField, 0); - expect(result.output).not.toHaveProperty('styles'); + expect(result.output.imageUrl).toBe(''); }); - // (k) output.label === '' for any IMAGE payload + // (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', - imageSource: 'URL', + description: 'Alt text', imageUrl: 'https://example.com/test-image.png', }; const result = returnImageCollector(mockField, 0); diff --git a/packages/davinci-client/src/lib/collector.utils.ts b/packages/davinci-client/src/lib/collector.utils.ts index 7b972030a8..dab3977304 100644 --- a/packages/davinci-client/src/lib/collector.utils.ts +++ b/packages/davinci-client/src/lib/collector.utils.ts @@ -989,20 +989,11 @@ 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 `error` and `output.label`: + * `output.{key, type}`. Overrides `output.label` (IMAGE has no wire `label` property; + * set to empty string — consumers use `output.description` for alt-text). * - * - `error`: `returnNoValueCollector` checks `'content' in field` and sets a missing-content - * error string, but `ImageField` has no `content` property — it has `imageUrl`. The factory - * replaces that derivation with an IMAGE-specific check: `error` is non-null only when - * `imageSource === 'URL'` and `imageUrl` is missing/empty (the FORM_NODE source is valid - * even with no static URL because the URL is runtime-injected). - * - `output.label`: IMAGE has no wire `label` property; set to empty string. Consumers should - * use `output.description` for alt-text purposes. - * - * Optional `hyperlink`, `customAttributes`, `styles`, and `description` are emitted via - * defensive conditional spread so absent properties do not appear on `output` at all. - * `output.customAttributes` is set only when both `enableCustomAttributes === true` AND - * `field.customAttributes` is present on the wire — never `{}`. + * `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`. @@ -1011,33 +1002,15 @@ export function returnQrCodeCollector(field: QrCodeField, idx: number): QrCodeCo export function returnImageCollector(field: ImageField, idx: number): ImageCollector { const base = returnNoValueCollector(field, idx, 'ImageCollector'); - const error = - field.imageSource === 'URL' && !field.imageUrl - ? 'imageUrl is required when imageSource is URL' - : null; - return { ...base, - error, + error: null, output: { ...base.output, label: '', - src: field.imageUrl || '', - imageSource: field.imageSource, - ...(field.enableHyperlink - ? { - hyperlink: { - url: field.hyperlinkUrl ?? '', - openInNewTab: field.openInNewTab ?? true, - source: field.hyperlinkSource ?? 'URL', - }, - } - : {}), - ...(field.enableCustomAttributes && field.customAttributes - ? { customAttributes: field.customAttributes } - : {}), - ...(field.styles ? { styles: field.styles } : {}), - ...(field.description !== undefined ? { description: field.description } : {}), + imageUrl: field.imageUrl, + description: field.description, + ...(field.hyperlinkUrl ? { hyperlinkUrl: field.hyperlinkUrl } : {}), }, }; } diff --git a/packages/davinci-client/src/lib/davinci.types.ts b/packages/davinci-client/src/lib/davinci.types.ts index 2dc9eddc4d..5ebdc7d8e6 100644 --- a/packages/davinci-client/src/lib/davinci.types.ts +++ b/packages/davinci-client/src/lib/davinci.types.ts @@ -140,65 +140,14 @@ export type QrCodeField = { fallbackText?: string; }; -/** - * Origin of an IMAGE field's URL value. `'URL'` means the URL is embedded in - * the form definition; `'FORM_NODE'` means the URL is injected at runtime by - * the DaVinci Show Form node connector. Used for both `imageSource` (the - * image itself) and `hyperlinkSource` (the click-through URL when hyperlink - * is enabled). - */ -export type FormImageSource = 'URL' | 'FORM_NODE'; - -/** - * Unit for `FormImageStyles.maxHeight` / `maxWidth`. Used twice in - * `FormImageStyles` (`maxHeightUnit` and `maxWidthUnit`); extracted to keep - * a single source of truth. - */ -export type FormImageLengthUnit = 'PIXELS' | 'PERCENT'; - -export type FormImageStyles = { - size: 'FIT' | 'SMALL' | 'MEDIUM' | 'LARGE' | 'CUSTOM'; - paddingPreset: 'NONE' | 'COMPACT' | 'COMFORTABLE' | 'CUSTOM'; - padding: { top: number; right: number; bottom: number; left: number }; - maxHeightUnit: FormImageLengthUnit; - maxWidthUnit: FormImageLengthUnit; - - // Optional properties - alignment?: 'LEFT' | 'CENTER' | 'RIGHT'; - maxHeight?: number; - maxWidth?: number; -}; - -export type FormImageCustomAttributes = { - // Optional properties - id?: string; - class?: string; - referrerPolicy?: - | 'no-referrer' - | 'no-referrer-when-downgrade' - | 'origin' - | 'origin-when-cross-origin' - | 'same-origin' - | 'strict-origin' - | 'strict-origin-when-cross-origin' - | 'unsafe-url'; -}; - export type ImageField = { type: 'IMAGE'; key: string; - imageSource: FormImageSource; + description: string; imageUrl: string; // Optional properties - description?: string; - styles?: FormImageStyles; - enableHyperlink?: boolean; - hyperlinkSource?: FormImageSource; hyperlinkUrl?: string; - openInNewTab?: boolean; - enableCustomAttributes?: boolean; - customAttributes?: FormImageCustomAttributes; }; export type AgreementField = { diff --git a/packages/davinci-client/src/lib/node.reducer.test.ts b/packages/davinci-client/src/lib/node.reducer.test.ts index ee99a36d9e..2fe2741637 100644 --- a/packages/davinci-client/src/lib/node.reducer.test.ts +++ b/packages/davinci-client/src/lib/node.reducer.test.ts @@ -446,8 +446,8 @@ describe('The node collector reducer', () => { key: 'image-0', label: '', type: 'IMAGE', - src: 'https://example.com/test-image.png', - imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', + description: 'Alt text', }, }, ]; @@ -541,26 +541,9 @@ describe('The node collector reducer', () => { { type: 'IMAGE', key: 'image-field', - imageSource: 'URL', imageUrl: 'https://example.com/test-image.png', description: 'Test image alt text', - styles: { - size: 'FIT', - paddingPreset: 'COMFORTABLE', - padding: { top: 20, right: 20, bottom: 20, left: 20 }, - maxHeightUnit: 'PIXELS', - maxWidthUnit: 'PIXELS', - alignment: 'CENTER', - }, - enableHyperlink: true, - hyperlinkSource: 'URL', hyperlinkUrl: 'https://example.com/click-target', - openInNewTab: true, - enableCustomAttributes: true, - customAttributes: { - id: 'test-image-id', - class: 'test-image-class', - }, }, ], formData: {}, @@ -578,26 +561,9 @@ describe('The node collector reducer', () => { key: 'image-field-0', label: '', type: 'IMAGE', - src: 'https://example.com/test-image.png', - imageSource: 'URL', + imageUrl: 'https://example.com/test-image.png', description: 'Test image alt text', - styles: { - size: 'FIT', - paddingPreset: 'COMFORTABLE', - padding: { top: 20, right: 20, bottom: 20, left: 20 }, - maxHeightUnit: 'PIXELS', - maxWidthUnit: 'PIXELS', - alignment: 'CENTER', - }, - hyperlink: { - url: 'https://example.com/click-target', - openInNewTab: true, - source: 'URL', - }, - customAttributes: { - id: 'test-image-id', - class: 'test-image-class', - }, + hyperlinkUrl: 'https://example.com/click-target', }, } satisfies ImageCollector, ]);