diff --git a/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts b/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts index 49c4996f36..885cc2145b 100644 --- a/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts +++ b/packages/twenty-front-component-renderer/src/host/hooks/useCaretPreservingElementRef.ts @@ -1,4 +1,4 @@ -import { isString } from '@sniptt/guards'; +import { isNumber, isString } from '@sniptt/guards'; import { useLayoutEffect, useRef, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -24,13 +24,13 @@ export const useCaretPreservingElementRef = ( useLayoutEffect(() => { const attachedElement = attachedElementRef.current; - if (!isDefined(attachedElement) || !isString(value)) { + if (!isDefined(attachedElement) || (!isString(value) && !isNumber(value))) { return; } syncValuePreservingCaret( attachedElement as HTMLInputElement | HTMLTextAreaElement, - value, + String(value), ); }); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts index 02d6d69ae8..fcc645ecc5 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createCaretPreservingElement.test.ts @@ -1,7 +1,14 @@ -import { type ReactElement } from 'react'; +import './setupServerRenderingGlobals'; + +import { act, type ReactElement } from 'react'; +import { createRoot } from 'react-dom/client'; import { createCaretPreservingElement } from '../createCaretPreservingElement'; +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + const getProps = (element: ReactElement): Record => element.props as Record; @@ -31,6 +38,30 @@ describe('createCaretPreservingElement', () => { expect(getProps(element).defaultValue).toBe('hello'); }); + it('should seed the initial value from a numeric defaultValue', () => { + const element = createCaretPreservingElement({ + htmlTag: 'input', + reactBindableProps: { type: 'number', defaultValue: 42 }, + hostEnforcedProps: {}, + setEditableFocused: null, + caretPreservingElementRef: () => {}, + }); + + expect(getProps(element).defaultValue).toBe('42'); + }); + + it('should seed the initial value from a numeric value', () => { + const element = createCaretPreservingElement({ + htmlTag: 'input', + reactBindableProps: { type: 'number', value: 42 }, + hostEnforcedProps: {}, + setEditableFocused: null, + caretPreservingElementRef: () => {}, + }); + + expect(getProps(element).defaultValue).toBe('42'); + }); + it('should prefer defaultValue over value for the initial value', () => { const element = createCaretPreservingElement({ htmlTag: 'input', @@ -87,4 +118,32 @@ describe('createCaretPreservingElement', () => { expect(setEditableFocused).toHaveBeenCalledWith(false); }); + + it('should forward the caret preserving ref to the rendered element', () => { + const caretPreservingElementRef = jest.fn(); + const element = createCaretPreservingElement({ + htmlTag: 'input', + reactBindableProps: {}, + hostEnforcedProps: {}, + setEditableFocused: null, + caretPreservingElementRef, + }); + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(element); + }); + + expect(caretPreservingElementRef).toHaveBeenCalledWith( + container.firstElementChild, + ); + + act(() => { + root.unmount(); + }); + container.remove(); + }); }); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts index 547c95dc8a..e99a5bde02 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/createHtmlHostWrapper.test.ts @@ -160,6 +160,23 @@ describe('createHtmlHostWrapper client events', () => { expect(node.value).toBe('fixed'); }); + it('should write a numeric controlled value to the host input', () => { + const Wrapper = createHtmlHostWrapper('input'); + + act(() => { + root.render(createElement(Wrapper, { type: 'number', value: 42 })); + }); + + const node = container.firstElementChild as HTMLInputElement; + expect(node.value).toBe('42'); + + act(() => { + root.render(createElement(Wrapper, { type: 'number', value: 43 })); + }); + + expect(node.value).toBe('43'); + }); + it('should clear the host input when a controlled value becomes empty', () => { const Wrapper = createHtmlHostWrapper('input'); diff --git a/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts b/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts index 3c227060d5..71340f02b1 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/createCaretPreservingElement.ts @@ -1,4 +1,4 @@ -import { isFunction, isNonEmptyString } from '@sniptt/guards'; +import { isFunction, isNonEmptyString, isNumber } from '@sniptt/guards'; import React from 'react'; import { type SetEditableFocused } from '@/host/contexts/FrontComponentInputFocusContext'; @@ -6,6 +6,18 @@ import { type ElementRefCallback } from '@/host/types/ElementRefCallback'; type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement; +const resolveInitialValue = (candidate: unknown): string | undefined => { + if (isNonEmptyString(candidate)) { + return candidate; + } + + if (isNumber(candidate)) { + return String(candidate); + } + + return undefined; +}; + type CreateCaretPreservingElementParams = { htmlTag: 'input' | 'textarea'; reactBindableProps: Record; @@ -28,11 +40,8 @@ export const createCaretPreservingElement = ({ onBlur: forwardedOnBlur, ...rest } = reactBindableProps; - const initialValue = isNonEmptyString(defaultValue) - ? defaultValue - : isNonEmptyString(value) - ? value - : undefined; + const initialValue = + resolveInitialValue(defaultValue) ?? resolveInitialValue(value); const handleFocus = (event: React.FocusEvent) => { setEditableFocused?.(true);