Add "number of decimal" on currency field. (#16439)

Closes [571](https://github.com/twentyhq/core-team-issues/issues/571).

Replicates the behavior of number field and allows specifying the number
of decimals for currency field.

<img width="931" height="858" alt="image"
src="https://github.com/user-attachments/assets/f5100d58-b1b0-4a88-a090-e98b2feeebd0"
/>

Currencies around the world have a maximum of three decimal places -
BHD, KWD etc. However, I have added a maximum of five decimal places in
case someone wants to use the currency field type for displaying things
like `per-second-billing` or `exchange rates`.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds configurable decimals (0–5) for currency fields, updating
settings UI, types/schema, display/aggregate formatting, and input
precision.
> 
> - **Currency field settings**:
> - Add `decimals` (0–5) to `FieldCurrencyMetadata.settings` and
validate via `currencyFieldSettingsSchema`.
> - Update `SettingsDataModelFieldCurrencyForm` to manage `format`
(short/full) and `decimals` with counter when `full`; wire defaults via
`useCurrencySettingsFormInitialValues`.
> - **Display & aggregation**:
> - `CurrencyDisplay` and
`transformAggregateRawValueIntoAggregateDisplayValue` honor `decimals`
when `format` is `full`; keep short format using `formatToShortNumber`.
> - **Input**:
>   - Increase currency `IMaskInput` precision with `scale=5`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
959a8fc1ea726d206548ea32cada28d77c1c6eb9. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdullah.
2025-12-26 22:53:43 +05:00
committed by GitHub
parent 28dc3e470e
commit c020f71ba1
8 changed files with 90 additions and 27 deletions
@@ -10,6 +10,7 @@ import { isFieldCurrency } from '@/object-record/record-field/ui/types/guards/is
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber';
export const useCurrencyField = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
@@ -37,6 +38,9 @@ export const useCurrencyField = () => {
const defaultValue = fieldDefinition.defaultValue;
const decimals =
fieldDefinition.metadata.settings?.decimals ?? DEFAULT_DECIMAL_VALUE;
return {
fieldDefinition,
fieldValue,
@@ -44,5 +48,6 @@ export const useCurrencyField = () => {
setDraftValue,
setFieldValue,
defaultValue,
decimals,
};
};
@@ -17,7 +17,8 @@ import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyTo
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const CurrencyFieldInput = () => {
const { draftValue, setDraftValue, defaultValue } = useCurrencyField();
const { draftValue, setDraftValue, defaultValue, decimals } =
useCurrencyField();
const { onClickOutside, onEnter, onEscape, onShiftTab, onTab } = useContext(
FieldInputEventContext,
@@ -137,6 +138,7 @@ export const CurrencyFieldInput = () => {
instanceId={instanceId}
value={draftValue?.amount?.toString() ?? ''}
currencyCode={currencyCode}
decimals={decimals}
autoFocus
placeholder={t`Currency`}
onClickOutside={handleClickOutside}
@@ -91,6 +91,7 @@ export type FieldCurrencyMetadata = BaseFieldMetadata & {
isPositive?: boolean;
settings?: {
format: FieldCurrencyFormat | null;
decimals?: number;
};
};
@@ -3,4 +3,5 @@ import { z } from 'zod';
export const currencyFieldSettingsSchema = z.object({
format: z.enum(fieldMetadataCurrencyFormat),
decimals: z.number().int().min(0).max(5).optional(),
});
@@ -1,15 +1,26 @@
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { type FieldCurrencyFormat } from '@/object-record/record-field/ui/types/FieldMetadata';
import {
fieldMetadataCurrencyFormat,
type FieldCurrencyFormat,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { currencyFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/currencyFieldDefaultValueSchema';
import { currencyFieldSettingsSchema } from '@/object-record/record-field/ui/validation-schemas/currencyFieldSettingsSchema';
import { Separator } from '@/settings/components/Separator';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { useCurrencySettingsFormInitialValues } from '@/settings/data-model/fields/forms/currency/hooks/useCurrencySettingsFormInitialValues';
import { Select } from '@/ui/input/components/Select';
import { plural } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { IconCheckbox, IconCurrencyDollar } from 'twenty-ui/display';
import {
IconCheckbox,
IconCurrencyDollar,
IconDecimal,
} from 'twenty-ui/display';
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
export const settingsDataModelFieldCurrencyFormSchema = z.object({
@@ -75,31 +86,65 @@ export const SettingsDataModelFieldCurrencyForm = ({
</SettingsOptionCardContentSelect>
)}
/>
<Separator />
<Controller
name="settings.format"
name="settings"
control={control}
defaultValue={initialSettingsValue.format}
render={({ field: { onChange, value } }) => (
<SettingsOptionCardContentSelect
Icon={IconCheckbox}
title={t`Format`}
description={t`Choose between Short and Full`}
>
<Select<FieldCurrencyFormat>
dropdownWidth={140}
value={value}
onChange={onChange}
disabled={disabled}
dropdownId="object-field-format-select"
options={[
{ label: 'Short', value: 'short' },
{ label: 'Full', value: 'full' },
]}
selectSizeVariant="small"
withSearchInput={false}
/>
</SettingsOptionCardContentSelect>
)}
defaultValue={initialSettingsValue}
render={({ field: { onChange, value } }) => {
const format = value?.format ?? fieldMetadataCurrencyFormat[0];
const decimals = value?.decimals ?? DEFAULT_DECIMAL_VALUE;
return (
<>
<SettingsOptionCardContentSelect
Icon={IconCheckbox}
title={t`Format`}
description={t`Choose between Short and Full`}
>
<Select<FieldCurrencyFormat>
dropdownWidth={140}
value={format}
onChange={(newFormat) =>
onChange({
format: newFormat,
decimals:
newFormat === fieldMetadataCurrencyFormat[0]
? DEFAULT_DECIMAL_VALUE
: decimals,
})
}
disabled={disabled}
dropdownId="object-field-format-select"
options={[
{ label: 'Short', value: fieldMetadataCurrencyFormat[0] },
{ label: 'Full', value: fieldMetadataCurrencyFormat[1] },
]}
selectSizeVariant="small"
withSearchInput={false}
/>
</SettingsOptionCardContentSelect>
<Separator />
{format === 'full' && (
<SettingsOptionCardContentCounter
Icon={IconDecimal}
title={t`Number of decimals`}
description={plural(decimals, {
one: `E.g. ${(1000).toFixed(decimals)} for ${decimals} decimal`,
other: `E.g. ${(1000).toFixed(decimals)} for ${decimals} decimals`,
})}
value={decimals}
onChange={(newDecimals: number) =>
onChange({ format, decimals: newDecimals })
}
disabled={disabled}
minValue={0}
maxValue={5}
/>
)}
</>
);
}}
/>
</>
);
@@ -4,6 +4,7 @@ import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetada
import { type SettingsDataModelFieldCurrencyFormValues } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencyForm';
import { isNonEmptyString } from '@sniptt/guards';
import { CurrencyCode } from 'twenty-shared/constants';
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
@@ -28,6 +29,7 @@ export const useCurrencySettingsFormInitialValues = ({
const initialFormValues: SettingsDataModelFieldCurrencyFormValues = {
settings: {
format: fieldMetadataItem?.settings?.format ?? 'short',
decimals: fieldMetadataItem?.settings?.decimals ?? DEFAULT_DECIMAL_VALUE,
},
defaultValue: {
amountMicros: initialAmountMicrosValue,
@@ -9,6 +9,7 @@ import {
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { isDefined } from 'twenty-shared/utils';
import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@@ -32,6 +33,9 @@ export const CurrencyDisplay = ({
: currencyValue?.amountMicros / 1000000;
const format = fieldDefinition.metadata.settings?.format;
const decimals = fieldDefinition.metadata.settings?.decimals;
const decimalsToUse = decimals ?? DEFAULT_DECIMAL_VALUE;
const { formatNumber } = useNumberFormat();
return (
@@ -48,7 +52,7 @@ export const CurrencyDisplay = ({
{amountToDisplay !== null
? !isDefined(format) || format === 'short'
? formatToShortNumber(amountToDisplay)
: formatNumber(amountToDisplay)
: formatNumber(amountToDisplay, { decimals: decimalsToUse })
: null}
</EllipsisDisplay>
);
@@ -41,6 +41,7 @@ export type CurrencyInputProps = {
placeholder?: string;
autoFocus?: boolean;
value: string;
decimals?: number;
currencyCode: string;
onEnter: (newText: string) => void;
onEscape: (newText: string) => void;
@@ -64,6 +65,7 @@ export const CurrencyInput = ({
onClickOutside,
onChange,
onSelect,
decimals,
}: CurrencyInputProps) => {
const theme = useTheme();
@@ -114,6 +116,7 @@ export const CurrencyInput = ({
mask={Number}
thousandsSeparator=","
radix="."
scale={decimals}
onAccept={(value: string) => handleChange(value)}
inputRef={wrapperRef}
autoComplete="off"