From af74f6cdff32f84074c8b5bc4d1b1cbdd688fc4b Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Tue, 21 Apr 2026 17:09:47 -0700 Subject: [PATCH] feat: add normalizeEmbeddedViewConfig utility and tests for configuration normalization --- .../hooks/useEmbeddedView/useEmbeddedView.ts | 10 +- .../utils/normalizeEmbeddedViewConfig.test.ts | 106 ++++++++++++++++++ .../utils/normalizeEmbeddedViewConfig.ts | 75 +++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 src/embedded/utils/normalizeEmbeddedViewConfig.test.ts create mode 100644 src/embedded/utils/normalizeEmbeddedViewConfig.ts diff --git a/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts index 3ebaca600..7c9559e77 100644 --- a/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts +++ b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts @@ -3,6 +3,7 @@ import { Iterable } from '../../../core/classes/Iterable'; import { IterableEmbeddedViewType } from '../../enums'; import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { normalizeEmbeddedViewConfig } from '../../utils/normalizeEmbeddedViewConfig'; import { getMedia } from './getMedia'; import { getStyles } from './getStyles'; @@ -44,9 +45,14 @@ export const useEmbeddedView = ( onMessageClick = noop, }: IterableEmbeddedComponentProps ) => { + const normalizedConfig = useMemo( + () => normalizeEmbeddedViewConfig(config), + [config] + ); + const parsedStyles = useMemo(() => { - return getStyles(viewType, config); - }, [viewType, config]); + return getStyles(viewType, normalizedConfig); + }, [viewType, normalizedConfig]); const media = useMemo(() => { return getMedia(viewType, message); }, [viewType, message]); diff --git a/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts b/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts new file mode 100644 index 000000000..96cfe6402 --- /dev/null +++ b/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts @@ -0,0 +1,106 @@ +import { normalizeEmbeddedViewConfig } from './normalizeEmbeddedViewConfig'; + +describe('normalizeEmbeddedViewConfig', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('returns null or undefined unchanged', () => { + expect(normalizeEmbeddedViewConfig(null)).toBeNull(); + expect(normalizeEmbeddedViewConfig(undefined)).toBeUndefined(); + }); + + it('parses numeric strings for borderWidth and borderCornerRadius', () => { + const input = { + borderWidth: '45', + borderCornerRadius: '12.5', + backgroundColor: '#fff', + }; + + // Runtime JSON / native payloads may use strings for numeric fields. + const result = normalizeEmbeddedViewConfig(input as never); + + expect(result).toEqual({ + borderWidth: 45, + borderCornerRadius: 12.5, + backgroundColor: '#fff', + }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('trims whitespace before parsing numeric strings', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: ' 8 ', + } as never); + + expect(result?.borderWidth).toBe(8); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('leaves valid numbers unchanged', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: 3, + borderCornerRadius: 0, + }); + + expect(result?.borderWidth).toBe(3); + expect(result?.borderCornerRadius).toBe(0); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('drops non-parsable strings and warns', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: 'nope', + borderCornerRadius: '10', + } as never); + + expect(result?.borderWidth).toBeUndefined(); + expect(result?.borderCornerRadius).toBe(10); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('borderWidth'); + }); + + it('drops empty strings and warns', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: ' ', + } as never); + + expect(result?.borderWidth).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('drops NaN and Infinity numbers and warns', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: Number.NaN, + borderCornerRadius: Number.POSITIVE_INFINITY, + } as never); + + expect(result?.borderWidth).toBeUndefined(); + expect(result?.borderCornerRadius).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it('drops invalid types and warns', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: true, + } as never); + + expect(result?.borderWidth).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('does not mutate the original config object', () => { + const original = { borderWidth: '7' as const }; + const snapshot = { ...original }; + + normalizeEmbeddedViewConfig(original as never); + + expect(original).toEqual(snapshot); + }); +}); diff --git a/src/embedded/utils/normalizeEmbeddedViewConfig.ts b/src/embedded/utils/normalizeEmbeddedViewConfig.ts new file mode 100644 index 000000000..63aa52eec --- /dev/null +++ b/src/embedded/utils/normalizeEmbeddedViewConfig.ts @@ -0,0 +1,75 @@ +import type { IterableEmbeddedViewConfig } from '../types/IterableEmbeddedViewConfig'; + +const NUMERIC_KEYS: (keyof Pick< + IterableEmbeddedViewConfig, + 'borderWidth' | 'borderCornerRadius' +>)[] = ['borderWidth', 'borderCornerRadius']; + +function coerceNumericField( + key: 'borderWidth' | 'borderCornerRadius', + value: unknown +): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === 'number') { + if (Number.isFinite(value)) { + return value; + } + console.warn( + `[IterableEmbeddedView] Ignoring ${String(key)}: expected a finite number, got ${String(value)}` + ); + return undefined; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed === '') { + console.warn( + `[IterableEmbeddedView] Ignoring ${String(key)}: empty string is not a valid number` + ); + return undefined; + } + const n = parseFloat(trimmed); + if (Number.isFinite(n)) { + return n; + } + console.warn( + `[IterableEmbeddedView] Ignoring ${String(key)}: could not parse string as a number: ${JSON.stringify(value)}` + ); + return undefined; + } + console.warn( + `[IterableEmbeddedView] Ignoring ${String(key)}: expected number or numeric string, got ${typeof value}` + ); + return undefined; +} + +/** + * Returns a shallow copy of config with numeric fields coerced from strings when possible. + * Values that cannot be coerced are omitted so style resolution can fall back to defaults. + */ +export function normalizeEmbeddedViewConfig( + config: IterableEmbeddedViewConfig | null | undefined +): IterableEmbeddedViewConfig | null | undefined { + if (config == null) { + return config; + } + const next: IterableEmbeddedViewConfig = { ...config }; + const loose = config as Record; + for (const key of NUMERIC_KEYS) { + const raw = loose[key as string]; + if (raw === undefined) { + continue; + } + if (typeof raw === 'number' && Number.isFinite(raw)) { + continue; + } + const coerced = coerceNumericField(key, raw); + if (coerced === undefined) { + delete next[key]; + } else { + next[key] = coerced; + } + } + return next; +}