diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/CurrencyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/CurrencyFieldInput.tsx
index 039ee6d5cd..f59d26d205 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/CurrencyFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/CurrencyFieldInput.tsx
@@ -10,6 +10,7 @@ import { useCurrencyField } from '@/object-record/record-field/ui/meta-types/hoo
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
+import { hasCurrencyValueChanged } from '@/object-record/record-field/ui/meta-types/input/utils/hasCurrencyValueChanged';
import { isFieldCurrencyValue } from '@/object-record/record-field/ui/types/guards/isFieldCurrencyValue';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useContext } from 'react';
@@ -17,7 +18,7 @@ import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyTo
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const CurrencyFieldInput = () => {
- const { draftValue, setDraftValue, defaultValue, decimals } =
+ const { fieldValue, draftValue, setDraftValue, defaultValue, decimals } =
useCurrencyField();
const { onClickOutside, onEnter, onEscape, onShiftTab, onTab } = useContext(
@@ -71,53 +72,39 @@ export const CurrencyFieldInput = () => {
return newCurrencyValue;
};
- const handleEnter = (newValue: string) => {
- onEnter?.({
- newValue: getNewCurrencyValue({
- amountText: newValue,
- currencyCode,
+ const getExitArgs = (amountText: string) => {
+ const newValue = getNewCurrencyValue({ amountText, currencyCode });
+
+ return {
+ newValue,
+ skipPersist: !hasCurrencyValueChanged({
+ newValue,
+ currentValue: fieldValue,
}),
- });
+ };
+ };
+
+ const handleEnter = (newValue: string) => {
+ onEnter?.(getExitArgs(newValue));
};
const handleEscape = (newValue: string) => {
- onEscape?.({
- newValue: getNewCurrencyValue({
- amountText: newValue,
- currencyCode,
- }),
- });
+ onEscape?.(getExitArgs(newValue));
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newValue: string,
) => {
- onClickOutside?.({
- newValue: getNewCurrencyValue({
- amountText: newValue,
- currencyCode,
- }),
- event,
- });
+ onClickOutside?.({ ...getExitArgs(newValue), event });
};
const handleTab = (newValue: string) => {
- onTab?.({
- newValue: getNewCurrencyValue({
- amountText: newValue,
- currencyCode,
- }),
- });
+ onTab?.(getExitArgs(newValue));
};
const handleShiftTab = (newValue: string) => {
- onShiftTab?.({
- newValue: getNewCurrencyValue({
- amountText: newValue,
- currencyCode,
- }),
- });
+ onShiftTab?.(getExitArgs(newValue));
};
const handleChange = (newValue: string) => {
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/CurrencyFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/CurrencyFieldInput.stories.tsx
new file mode 100644
index 0000000000..46cee908fa
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/__stories__/CurrencyFieldInput.stories.tsx
@@ -0,0 +1,251 @@
+import {
+ type Decorator,
+ type Meta,
+ type StoryObj,
+} from '@storybook/react-vite';
+import { useEffect, useState } from 'react';
+import { expect, userEvent, waitFor, within } from 'storybook/test';
+
+import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
+import { FieldMetadataType } from '~/generated-metadata/graphql';
+import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
+
+import { NumberFormat } from '@/localization/constants/NumberFormat';
+import { workspaceMemberFormatPreferencesState } from '@/localization/states/workspaceMemberFormatPreferencesState';
+import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext';
+import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
+import { useCurrencyField } from '@/object-record/record-field/ui/meta-types/hooks/useCurrencyField';
+import { CurrencyFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/CurrencyFieldInput';
+import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/ui/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks';
+import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
+import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix';
+import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
+import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
+import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
+import { CurrencyCode } from 'twenty-shared/constants';
+import { StorybookFieldInputDropdownFocusIdSetterEffect } from '~/testing/components/StorybookFieldInputDropdownFocusIdSetterEffect';
+
+const {
+ FieldInputEventContextProviderWithJestMocks,
+ handleEnterMocked,
+ handleEscapeMocked,
+ handleClickoutsideMocked,
+ handleTabMocked,
+ handleShiftTabMocked,
+} = getFieldInputEventContextProviderWithJestMocks();
+
+const AMOUNT_MICROS_WITH_CENTS = 458640000;
+
+const CurrencyFieldValueSetterEffect = ({
+ amountMicros,
+ numberFormat,
+}: {
+ amountMicros: number;
+ numberFormat: NumberFormat;
+}) => {
+ const { setFieldValue, setDraftValue } = useCurrencyField();
+ const setFormatPreferences = useSetAtomState(
+ workspaceMemberFormatPreferencesState,
+ );
+
+ useEffect(() => {
+ setFormatPreferences((previous) => ({ ...previous, numberFormat }));
+ setFieldValue({ amountMicros, currencyCode: CurrencyCode.USD });
+ setDraftValue({
+ amount: (amountMicros / 1000000).toString(),
+ currencyCode: CurrencyCode.USD,
+ });
+ }, [
+ setFieldValue,
+ setDraftValue,
+ amountMicros,
+ setFormatPreferences,
+ numberFormat,
+ ]);
+
+ return <>>;
+};
+
+type CurrencyFieldInputWithContextProps = {
+ amountMicros: number;
+ numberFormat: NumberFormat;
+ recordId: string;
+};
+
+const CurrencyFieldInputWithContext = ({
+ recordId,
+ amountMicros,
+ numberFormat,
+}: CurrencyFieldInputWithContextProps) => {
+ const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
+
+ const [isReady, setIsReady] = useState(false);
+
+ const instanceId = getRecordFieldInputInstanceId({
+ recordId,
+ fieldName: 'Amount',
+ prefix: RECORD_TABLE_CELL_INPUT_ID_PREFIX,
+ });
+
+ useEffect(() => {
+ if (!isReady) {
+ pushFocusItemToFocusStack({
+ focusId: instanceId,
+ component: {
+ type: FocusComponentType.OPENED_FIELD_INPUT,
+ instanceId: instanceId,
+ },
+ });
+ setIsReady(true);
+ }
+ }, [isReady, pushFocusItemToFocusStack, instanceId]);
+
+ return (
+
+
+
+
+ {isReady && }
+
+
+
+
+
+ {isReady && }
+
+
+ );
+};
+
+const clearMocksDecorator: Decorator = (Story, context) => {
+ if (context.parameters.clearMocks === true) {
+ handleEnterMocked.mockClear();
+ handleEscapeMocked.mockClear();
+ handleClickoutsideMocked.mockClear();
+ handleTabMocked.mockClear();
+ handleShiftTabMocked.mockClear();
+ }
+ return ;
+};
+
+const meta: Meta = {
+ title: 'UI/Data/Field/Input/CurrencyFieldInput',
+ component: CurrencyFieldInputWithContext,
+ args: {
+ recordId: '123',
+ amountMicros: AMOUNT_MICROS_WITH_CENTS,
+ numberFormat: NumberFormat.DOTS_AND_COMMA,
+ },
+ decorators: [clearMocksDecorator, SnackBarDecorator],
+ parameters: {
+ clearMocks: true,
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const ClickOutsideKeepsCentsWithDotsAndComma: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ expect(handleClickoutsideMocked).toHaveBeenCalledTimes(0);
+
+ await canvas.findByTestId('is-ready-marker');
+ await userEvent.click(
+ canvas.getByTestId('data-field-input-click-outside-div'),
+ );
+
+ await waitFor(() => {
+ expect(handleClickoutsideMocked).toHaveBeenCalledTimes(1);
+ });
+
+ expect(handleClickoutsideMocked).toHaveBeenCalledWith(
+ expect.objectContaining({
+ newValue: {
+ amountMicros: AMOUNT_MICROS_WITH_CENTS,
+ currencyCode: CurrencyCode.USD,
+ },
+ skipPersist: true,
+ }),
+ );
+ },
+};
+
+export const ClickOutsideKeepsCentsWithCommasAndDot: Story = {
+ args: { numberFormat: NumberFormat.COMMAS_AND_DOT },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ expect(handleClickoutsideMocked).toHaveBeenCalledTimes(0);
+
+ await canvas.findByTestId('is-ready-marker');
+ await userEvent.click(
+ canvas.getByTestId('data-field-input-click-outside-div'),
+ );
+
+ await waitFor(() => {
+ expect(handleClickoutsideMocked).toHaveBeenCalledTimes(1);
+ });
+
+ expect(handleClickoutsideMocked).toHaveBeenCalledWith(
+ expect.objectContaining({
+ newValue: {
+ amountMicros: AMOUNT_MICROS_WITH_CENTS,
+ currencyCode: CurrencyCode.USD,
+ },
+ skipPersist: true,
+ }),
+ );
+ },
+};
+
+export const EscapeKeepsCents: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ expect(handleEscapeMocked).toHaveBeenCalledTimes(0);
+
+ await canvas.findByTestId('is-ready-marker');
+ await userEvent.keyboard('{esc}');
+
+ await waitFor(() => {
+ expect(handleEscapeMocked).toHaveBeenCalledTimes(1);
+ });
+
+ expect(handleEscapeMocked).toHaveBeenCalledWith(
+ expect.objectContaining({
+ newValue: {
+ amountMicros: AMOUNT_MICROS_WITH_CENTS,
+ currencyCode: CurrencyCode.USD,
+ },
+ skipPersist: true,
+ }),
+ );
+ },
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/__tests__/hasCurrencyValueChanged.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/__tests__/hasCurrencyValueChanged.test.ts
new file mode 100644
index 0000000000..dc8b5d7004
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/__tests__/hasCurrencyValueChanged.test.ts
@@ -0,0 +1,34 @@
+import { hasCurrencyValueChanged } from '@/object-record/record-field/ui/meta-types/input/utils/hasCurrencyValueChanged';
+import { CurrencyCode } from 'twenty-shared/constants';
+
+describe('hasCurrencyValueChanged', () => {
+ it('should not report a change when reopening a field leaves the value untouched', () => {
+ expect(
+ hasCurrencyValueChanged({
+ newValue: { amountMicros: 458640000, currencyCode: CurrencyCode.USD },
+ currentValue: {
+ amountMicros: 458640000,
+ currencyCode: CurrencyCode.USD,
+ },
+ }),
+ ).toBe(false);
+ });
+
+ it('should report a change when the amount or currency differs', () => {
+ expect(
+ hasCurrencyValueChanged({
+ newValue: { amountMicros: 45864000000, currencyCode: CurrencyCode.EUR },
+ currentValue: {
+ amountMicros: 458640000,
+ currencyCode: CurrencyCode.USD,
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('should report a change when either side is not a currency value', () => {
+ expect(
+ hasCurrencyValueChanged({ newValue: undefined, currentValue: null }),
+ ).toBe(true);
+ });
+});
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/hasCurrencyValueChanged.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/hasCurrencyValueChanged.ts
new file mode 100644
index 0000000000..3a73624272
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/utils/hasCurrencyValueChanged.ts
@@ -0,0 +1,21 @@
+import { type FieldCurrencyValue } from '@/object-record/record-field/ui/types/FieldMetadata';
+import { isFieldCurrencyValue } from '@/object-record/record-field/ui/types/guards/isFieldCurrencyValue';
+
+type HasCurrencyValueChangedParams = {
+ newValue: FieldCurrencyValue | undefined;
+ currentValue: unknown;
+};
+
+export const hasCurrencyValueChanged = ({
+ newValue,
+ currentValue,
+}: HasCurrencyValueChangedParams): boolean => {
+ if (!isFieldCurrencyValue(newValue) || !isFieldCurrencyValue(currentValue)) {
+ return true;
+ }
+
+ return (
+ newValue.amountMicros !== currentValue.amountMicros ||
+ newValue.currencyCode !== currentValue.currencyCode
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/field/input/components/CurrencyInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/CurrencyInput.tsx
index 4c2db1732c..804039d437 100644
--- a/packages/twenty-front/src/modules/ui/field/input/components/CurrencyInput.tsx
+++ b/packages/twenty-front/src/modules/ui/field/input/components/CurrencyInput.tsx
@@ -6,6 +6,7 @@ import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-typ
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { CurrencyPickerDropdownButton } from '@/ui/input/components/internal/currency/components/CurrencyPickerDropdownButton';
+import { getSafeScaleForCurrencyInput } from '@/ui/field/input/utils/getSafeScaleForCurrencyInput';
import { type Currency } from '@/ui/input/components/internal/types/Currency';
import { IMaskInput } from 'react-imask';
import { type IconComponent } from 'twenty-ui/icon';
@@ -89,6 +90,9 @@ export const CurrencyInput = ({
}: CurrencyInputProps) => {
const { theme } = useContext(ThemeContext);
const [internalText, setInternalText] = useState(value);
+ const [scale, setScale] = useState(() =>
+ getSafeScaleForCurrencyInput({ value, decimals }),
+ );
const { numberFormat } = useNumberFormat();
const wrapperRef = useRef(null);
@@ -96,7 +100,13 @@ export const CurrencyInput = ({
const { thousandsSeparator, radix } =
getSeparatorsForNumberFormat(numberFormat);
- const handleChange = (value: string) => {
+ // imask re-emits accept while formatting the incoming value, with no
+ // originating input event; only a user keystroke may change the draft
+ const handleAccept = (value: string, event?: InputEvent) => {
+ if (!isDefined(event)) {
+ return;
+ }
+
setInternalText(value);
onChange?.(value);
};
@@ -118,6 +128,17 @@ export const CurrencyInput = ({
const currency = CURRENCIES.find(({ value }) => value === currencyCode);
+ const scaleForCurrentValue = getSafeScaleForCurrencyInput({
+ value,
+ decimals,
+ });
+
+ // deleting a decimal must not narrow the mask for the rest of the edit,
+ // it would make the digit impossible to type back
+ if (scale < scaleForCurrentValue) {
+ setScale(scaleForCurrentValue);
+ }
+
useEffect(() => {
setInternalText(value);
}, [value]);
@@ -140,8 +161,10 @@ export const CurrencyInput = ({
mask={Number}
thousandsSeparator={thousandsSeparator}
radix={radix}
- scale={decimals}
- onAccept={(value: string) => handleChange(value)}
+ scale={scale}
+ onAccept={(value: string, _maskRef: unknown, event?: InputEvent) =>
+ handleAccept(value, event)
+ }
inputRef={wrapperRef}
autoComplete="off"
placeholder={placeholder}
diff --git a/packages/twenty-front/src/modules/ui/field/input/utils/__tests__/getSafeScaleForCurrencyInput.test.ts b/packages/twenty-front/src/modules/ui/field/input/utils/__tests__/getSafeScaleForCurrencyInput.test.ts
new file mode 100644
index 0000000000..14e63a7c0a
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/field/input/utils/__tests__/getSafeScaleForCurrencyInput.test.ts
@@ -0,0 +1,66 @@
+import { IMask } from 'react-imask';
+
+import { NumberFormat } from '@/localization/constants/NumberFormat';
+import { getSafeScaleForCurrencyInput } from '@/ui/field/input/utils/getSafeScaleForCurrencyInput';
+import { getSeparatorsForNumberFormat } from '~/utils/format/getSeparatorsForNumberFormat';
+
+describe('getSafeScaleForCurrencyInput', () => {
+ it('should keep the field decimals when the value has no decimal part', () => {
+ expect(getSafeScaleForCurrencyInput({ value: '458', decimals: 2 })).toBe(2);
+ });
+
+ it('should keep the field decimals when the value fits in it', () => {
+ expect(getSafeScaleForCurrencyInput({ value: '458.6', decimals: 2 })).toBe(
+ 2,
+ );
+ });
+
+ it('should widen the scale to the decimals present in the value', () => {
+ expect(getSafeScaleForCurrencyInput({ value: '458.64', decimals: 0 })).toBe(
+ 2,
+ );
+ });
+
+ it('should handle negative values', () => {
+ expect(
+ getSafeScaleForCurrencyInput({ value: '-458.64', decimals: 0 }),
+ ).toBe(2);
+ });
+
+ it('should default to no decimals when neither decimals nor value provide any', () => {
+ expect(getSafeScaleForCurrencyInput({ value: '' })).toBe(0);
+ });
+
+ it('should ignore a value that is not a plain unmasked number', () => {
+ expect(
+ getSafeScaleForCurrencyInput({ value: '1.234,56', decimals: 1 }),
+ ).toBe(1);
+ });
+});
+
+describe('currency mask round trip with the safe scale', () => {
+ it.each([
+ [NumberFormat.DOTS_AND_COMMA, '458,64'],
+ [NumberFormat.COMMAS_AND_DOT, '458.64'],
+ [NumberFormat.SPACES_AND_COMMA, '458,64'],
+ [NumberFormat.APOSTROPHE_AND_DOT, '458.64'],
+ ])(
+ 'should not alter 458.64 with decimals 0 under the %s format',
+ (numberFormat, expectedMaskedValue) => {
+ const { thousandsSeparator, radix } =
+ getSeparatorsForNumberFormat(numberFormat);
+
+ const mask = IMask.createMask({
+ mask: Number,
+ thousandsSeparator,
+ radix,
+ scale: getSafeScaleForCurrencyInput({ value: '458.64', decimals: 0 }),
+ });
+
+ mask.unmaskedValue = '458.64';
+
+ expect(mask.value).toBe(expectedMaskedValue);
+ expect(mask.unmaskedValue).toBe('458.64');
+ },
+ );
+});
diff --git a/packages/twenty-front/src/modules/ui/field/input/utils/getSafeScaleForCurrencyInput.ts b/packages/twenty-front/src/modules/ui/field/input/utils/getSafeScaleForCurrencyInput.ts
new file mode 100644
index 0000000000..1ac77a0607
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/field/input/utils/getSafeScaleForCurrencyInput.ts
@@ -0,0 +1,17 @@
+import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber';
+
+type GetSafeScaleForCurrencyInputParams = {
+ value: string;
+ decimals?: number;
+};
+
+const UNMASKED_VALUE_PATTERN = /^-?\d*\.(\d+)$/;
+
+export const getSafeScaleForCurrencyInput = ({
+ value,
+ decimals = DEFAULT_DECIMAL_VALUE,
+}: GetSafeScaleForCurrencyInputParams): number => {
+ const decimalPart = UNMASKED_VALUE_PATTERN.exec(value)?.[1];
+
+ return Math.max(decimals, decimalPart?.length ?? 0);
+};