Fix numeric controlled input values in front components (#23421)
A front component rendering a numeric input never showed its value
because the caret-preserving path only accepted strings:
- controlled: `<input type="number" value={42}>` was rejected by the
value sync guard, so nothing was written to the host element
- uncontrolled: `defaultValue={42}` was dropped from the initial value
seeding
Numeric values are now stringified in both places. Also adds a test
asserting `createCaretPreservingElement` forwards its ref to the
rendered element.
The controlled case and the ref test were flagged by cubic on #23264
This commit is contained in:
+3
-3
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+60
-1
@@ -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<string, unknown> =>
|
||||
element.props as Record<string, unknown>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+17
@@ -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');
|
||||
|
||||
|
||||
+15
-6
@@ -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<string, unknown>;
|
||||
@@ -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<CaretPreservingElement>) => {
|
||||
setEditableFocused?.(true);
|
||||
|
||||
Reference in New Issue
Block a user