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:
+5
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+3
-1
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -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(),
|
||||
});
|
||||
|
||||
+70
-25
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user