diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts
index f3c3e93f1b2c..de52cbd8fe73 100644
--- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts
+++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts
@@ -51,6 +51,7 @@ export const queries = {
${baseFields}
defaultValue
options
+ settings
}
}
`,
diff --git a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
index 61ce60263dfc..8a403a55a379 100644
--- a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
+++ b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
@@ -17,7 +17,7 @@ export type FieldMetadataItemOption = {
export type FieldMetadataItem = Omit<
Field,
- '__typename' | 'defaultValue' | 'options' | 'settings' | 'relationDefinition'
+ '__typename' | 'defaultValue' | 'options' | 'relationDefinition'
> & {
__typename?: string;
defaultValue?: any;
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts
index 8ba9ebe23315..f372cd2eb3ac 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts
@@ -54,5 +54,6 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
metadata: fieldDefintionMetadata,
type: field.type,
}),
+ settings: field.settings,
};
};
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx
index 4bfb9f2557fc..7d12d1ab213d 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx
@@ -342,6 +342,7 @@ export const RecordBoardCard = ({
metadata: fieldDefinition.metadata,
type: fieldDefinition.type,
}),
+ settings: fieldDefinition.settings,
},
useUpdateRecord: useUpdateOneRecordHook,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/NumberFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/NumberFieldDisplay.tsx
index 087a4117c47b..cb30dbed3776 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/NumberFieldDisplay.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/NumberFieldDisplay.tsx
@@ -2,7 +2,11 @@ import { useNumberFieldDisplay } from '@/object-record/record-field/meta-types/h
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
export const NumberFieldDisplay = () => {
- const { fieldValue } = useNumberFieldDisplay();
-
- return ;
+ const { fieldValue, fieldDefinition } = useNumberFieldDisplay();
+ return (
+
+ );
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts
index 5bdceda11e73..097bcb8beef5 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts
@@ -5,10 +5,11 @@ import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecor
import { FieldNumberValue } from '@/object-record/record-field/types/FieldMetadata';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated-metadata/graphql';
+
import {
- canBeCastAsIntegerOrNull,
- castAsIntegerOrNull,
-} from '~/utils/cast-as-integer-or-null';
+ canBeCastAsNumberOrNull,
+ castAsNumberOrNull,
+} from '~/utils/cast-as-number-or-null';
import { FieldContext } from '../../contexts/FieldContext';
import { usePersistField } from '../../hooks/usePersistField';
@@ -32,11 +33,11 @@ export const useNumberField = () => {
const persistField = usePersistField();
const persistNumberField = (newValue: string) => {
- if (!canBeCastAsIntegerOrNull(newValue)) {
+ if (!canBeCastAsNumberOrNull(newValue)) {
return;
}
- const castedValue = castAsIntegerOrNull(newValue);
+ const castedValue = castAsNumberOrNull(newValue);
persistField(castedValue);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts
index 16f05f2418d2..1d8107c17953 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts
@@ -16,4 +16,7 @@ export type FieldDefinition = {
infoTooltipContent?: string;
defaultValue?: any;
editButtonIcon?: IconComponent;
+ settings?: {
+ decimals?: number;
+ };
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema.ts
new file mode 100644
index 000000000000..48981e4a5a34
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod';
+
+export const numberFieldDefaultValueSchema = z.object({
+ decimals: z.number().nullable(),
+});
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
index a71cc1654bcf..882b57f7d1c1 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
@@ -11,6 +11,8 @@ import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/
import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
import { SettingsDataModelFieldDateSettingsFormCard } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard';
+import { settingsDataModelFieldNumberFormSchema } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
+import { SettingsDataModelFieldNumberSettingsFormCard } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard';
import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm';
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard';
import {
@@ -52,6 +54,10 @@ const multiSelectFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.MultiSelect) })
.merge(settingsDataModelFieldMultiSelectFormSchema);
+const numberFieldFormSchema = z
+ .object({ type: z.literal(FieldMetadataType.Number) })
+ .merge(settingsDataModelFieldNumberFormSchema);
+
const otherFieldsFormSchema = z.object({
type: z.enum(
Object.keys(
@@ -63,6 +69,7 @@ const otherFieldsFormSchema = z.object({
FieldMetadataType.MultiSelect,
FieldMetadataType.Date,
FieldMetadataType.DateTime,
+ FieldMetadataType.Number,
]),
) as [FieldMetadataType, ...FieldMetadataType[]],
),
@@ -78,13 +85,17 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
relationFieldFormSchema,
selectFieldFormSchema,
multiSelectFieldFormSchema,
+ numberFieldFormSchema,
otherFieldsFormSchema,
],
);
type SettingsDataModelFieldSettingsFormCardProps = {
isCreatingField?: boolean;
- fieldMetadataItem: Pick &
+ fieldMetadataItem: Pick<
+ FieldMetadataItem,
+ 'icon' | 'label' | 'type' | 'isCustom'
+ > &
Partial>;
} & Pick;
@@ -163,6 +174,16 @@ export const SettingsDataModelFieldSettingsFormCard = ({
);
}
+ if (fieldMetadataItem.type === FieldMetadataType.Number) {
+ return (
+
+ );
+ }
+
if (
fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx
new file mode 100644
index 000000000000..706bcea37154
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx
@@ -0,0 +1,168 @@
+import styled from '@emotion/styled';
+
+import { Button } from '@/ui/input/button/components/Button';
+import { TextInput } from '@/ui/input/components/TextInput';
+import { IconInfoCircle, IconMinus, IconPlus } from 'twenty-ui';
+import { castAsNumberOrNull } from '~/utils/cast-as-number-or-null';
+
+type SettingsDataModelFieldNumberDecimalsInputProps = {
+ value: number;
+ onChange: (value: number) => void;
+ disabled?: boolean;
+};
+
+const StyledCounterContainer = styled.div`
+ align-items: center;
+ background: ${({ theme }) => theme.background.noisy};
+ border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ gap: ${({ theme }) => theme.spacing(1)};
+ justify-content: center;
+`;
+
+const StyledExampleText = styled.div`
+ color: ${({ theme }) => theme.font.color.primary};
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: ${({ theme }) => theme.font.weight.regular};
+`;
+
+const StyledCounterControlsIcons = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledCounterInnerContainer = styled.div`
+ align-items: center;
+ align-self: stretch;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(1)};
+ padding: ${({ theme }) => theme.spacing(2)};
+ height: 24px;
+`;
+
+const StyledTextInput = styled(TextInput)`
+ width: ${({ theme }) => theme.spacing(16)};
+ input {
+ width: ${({ theme }) => theme.spacing(16)};
+ height: ${({ theme }) => theme.spacing(6)};
+ text-align: center;
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+ background: ${({ theme }) => theme.background.noisy};
+ }
+ input ~ div {
+ padding-right: ${({ theme }) => theme.spacing(0)};
+ border-radius: ${({ theme }) => theme.spacing(1)};
+ background: ${({ theme }) => theme.background.noisy};
+ }
+`;
+
+const StyledTitle = styled.div`
+ color: ${({ theme }) => theme.font.color.light};
+ font-size: ${({ theme }) => theme.font.size.xs};
+ font-weight: ${({ theme }) => theme.font.weight.semiBold};
+ margin-bottom: ${({ theme }) => theme.spacing(1)};
+`;
+
+const StyledControlButton = styled(Button)`
+ height: ${({ theme }) => theme.spacing(6)};
+ width: ${({ theme }) => theme.spacing(6)};
+ padding: 0;
+ justify-content: center;
+ svg {
+ height: ${({ theme }) => theme.spacing(4)};
+ width: ${({ theme }) => theme.spacing(4)};
+ }
+`;
+
+const StyledInfoButton = styled(Button)`
+ height: ${({ theme }) => theme.spacing(6)};
+ width: ${({ theme }) => theme.spacing(6)};
+ padding: 0;
+ justify-content: center;
+ svg {
+ color: ${({ theme }) => theme.font.color.extraLight};
+ height: ${({ theme }) => theme.spacing(4)};
+ width: ${({ theme }) => theme.spacing(4)};
+ }
+`;
+
+const MIN_VALUE = 0;
+const MAX_VALUE = 100;
+export const SettingsDataModelFieldNumberDecimalsInput = ({
+ value,
+ onChange,
+ disabled,
+}: SettingsDataModelFieldNumberDecimalsInputProps) => {
+ const exampleValue = (1000).toFixed(value);
+
+ const handleIncrementCounter = () => {
+ if (value < MAX_VALUE) {
+ const newValue = value + 1;
+ onChange(newValue);
+ }
+ };
+
+ const handleDecrementCounter = () => {
+ if (value > MIN_VALUE) {
+ const newValue = value - 1;
+ onChange(newValue);
+ }
+ };
+
+ const handleTextInputChange = (value: string) => {
+ const castedNumber = castAsNumberOrNull(value);
+ if (castedNumber === null) {
+ onChange(MIN_VALUE);
+ return;
+ }
+
+ if (castedNumber < MIN_VALUE) {
+ return;
+ }
+
+ if (castedNumber > MAX_VALUE) {
+ onChange(MAX_VALUE);
+ return;
+ }
+ onChange(castedNumber);
+ };
+ return (
+ <>
+ Number of decimals
+
+
+ Example: {exampleValue}
+
+
+
+ handleTextInputChange(value)}
+ disabled={disabled}
+ />
+
+
+
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx
new file mode 100644
index 000000000000..3a80bf0e6104
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx
@@ -0,0 +1,55 @@
+import { Controller, useFormContext } from 'react-hook-form';
+import { z } from 'zod';
+
+import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { numberFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema';
+import { SettingsDataModelFieldNumberDecimalsInput } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput';
+import { CardContent } from '@/ui/layout/card/components/CardContent';
+import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/number';
+
+export const settingsDataModelFieldNumberFormSchema = z.object({
+ settings: numberFieldDefaultValueSchema,
+});
+
+export type SettingsDataModelFieldNumberFormValues = z.infer<
+ typeof settingsDataModelFieldNumberFormSchema
+>;
+
+type SettingsDataModelFieldNumberFormProps = {
+ disabled?: boolean;
+ fieldMetadataItem: Pick<
+ FieldMetadataItem,
+ 'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
+ >;
+};
+
+export const SettingsDataModelFieldNumberForm = ({
+ disabled,
+ fieldMetadataItem,
+}: SettingsDataModelFieldNumberFormProps) => {
+ const { control } = useFormContext();
+
+ return (
+
+ {
+ const count = value?.decimals ?? 0;
+
+ return (
+ onChange({ decimals: value })}
+ disabled={disabled}
+ >
+ );
+ }}
+ />
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx
new file mode 100644
index 000000000000..edea86760fbf
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx
@@ -0,0 +1,45 @@
+import styled from '@emotion/styled';
+
+import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
+import { SettingsDataModelFieldNumberForm } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
+import {
+ SettingsDataModelFieldPreviewCard,
+ SettingsDataModelFieldPreviewCardProps,
+} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
+
+type SettingsDataModelFieldNumberSettingsFormCardProps = {
+ disabled?: boolean;
+ fieldMetadataItem: Pick<
+ FieldMetadataItem,
+ 'icon' | 'label' | 'type' | 'defaultValue'
+ >;
+} & Pick;
+
+const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
+ display: grid;
+ flex: 1 1 100%;
+`;
+
+export const SettingsDataModelFieldNumberSettingsFormCard = ({
+ disabled,
+ fieldMetadataItem,
+ objectMetadataItem,
+}: SettingsDataModelFieldNumberSettingsFormCardProps) => {
+ return (
+
+ }
+ form={
+
+ }
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/NumberDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/NumberDisplay.tsx
index 1834e502a051..cef5ff6e0b81 100644
--- a/packages/twenty-front/src/modules/ui/field/display/components/NumberDisplay.tsx
+++ b/packages/twenty-front/src/modules/ui/field/display/components/NumberDisplay.tsx
@@ -4,8 +4,11 @@ import { EllipsisDisplay } from './EllipsisDisplay';
type NumberDisplayProps = {
value: string | number | null | undefined;
+ decimals?: number;
};
-export const NumberDisplay = ({ value }: NumberDisplayProps) => (
- {value && formatNumber(Number(value))}
+export const NumberDisplay = ({ value, decimals }: NumberDisplayProps) => (
+
+ {value && formatNumber(Number(value), decimals)}
+
);
diff --git a/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts b/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts
index 139ac042751f..cbf5f19b35ae 100644
--- a/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts
+++ b/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts
@@ -50,6 +50,7 @@ export const mapViewFieldsToColumnDefinitions = ({
isSortable: correspondingColumnDefinition.isSortable,
isFilterable: correspondingColumnDefinition.isFilterable,
defaultValue: correspondingColumnDefinition.defaultValue,
+ settings: correspondingColumnDefinition.settings,
} as ColumnDefinition;
})
.filter(isDefined);
diff --git a/packages/twenty-front/src/utils/__tests__/cast-as-integer-or-null.test.ts b/packages/twenty-front/src/utils/__tests__/cast-as-integer-or-null.test.ts
deleted file mode 100644
index cc077afdb27c..000000000000
--- a/packages/twenty-front/src/utils/__tests__/cast-as-integer-or-null.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- canBeCastAsIntegerOrNull,
- castAsIntegerOrNull,
-} from '../cast-as-integer-or-null';
-
-describe('canBeCastAsIntegerOrNull', () => {
- it(`should return true if null`, () => {
- expect(canBeCastAsIntegerOrNull(null)).toBeTruthy();
- });
-
- it(`should return true if number`, () => {
- expect(canBeCastAsIntegerOrNull(9)).toBeTruthy();
- });
-
- it(`should return true if empty string`, () => {
- expect(canBeCastAsIntegerOrNull('')).toBeTruthy();
- });
-
- it(`should return true if integer string`, () => {
- expect(canBeCastAsIntegerOrNull('9')).toBeTruthy();
- });
-
- it(`should return false if undefined`, () => {
- expect(canBeCastAsIntegerOrNull(undefined)).toBeFalsy();
- });
-
- it(`should return false if non numeric string`, () => {
- expect(canBeCastAsIntegerOrNull('9a')).toBeFalsy();
- });
-
- it(`should return false if non numeric string #2`, () => {
- expect(canBeCastAsIntegerOrNull('a9a')).toBeFalsy();
- });
-
- it(`should return false if float`, () => {
- expect(canBeCastAsIntegerOrNull(0.9)).toBeFalsy();
- });
-
- it(`should return false if float string`, () => {
- expect(canBeCastAsIntegerOrNull('0.9')).toBeFalsy();
- });
-});
-
-describe('castAsIntegerOrNull', () => {
- it(`should cast null to null`, () => {
- expect(castAsIntegerOrNull(null)).toBe(null);
- });
-
- it(`should cast empty string to null`, () => {
- expect(castAsIntegerOrNull('')).toBe(null);
- });
-
- it(`should cast an integer to an integer`, () => {
- expect(castAsIntegerOrNull(9)).toBe(9);
- });
-
- it(`should cast an integer string to an integer`, () => {
- expect(castAsIntegerOrNull('9')).toBe(9);
- });
-
- it(`should throw if trying to cast a float string to an integer`, () => {
- expect(() => castAsIntegerOrNull('9.9')).toThrow(Error);
- });
-
- it(`should throw if trying to cast a non numeric string to an integer`, () => {
- expect(() => castAsIntegerOrNull('9.9a')).toThrow(Error);
- });
-
- it(`should throw if trying to cast an undefined to an integer`, () => {
- expect(() => castAsIntegerOrNull(undefined)).toThrow(Error);
- });
-});
diff --git a/packages/twenty-front/src/utils/__tests__/cast-as-number-or-null.test.ts b/packages/twenty-front/src/utils/__tests__/cast-as-number-or-null.test.ts
new file mode 100644
index 000000000000..082527de7ec7
--- /dev/null
+++ b/packages/twenty-front/src/utils/__tests__/cast-as-number-or-null.test.ts
@@ -0,0 +1,72 @@
+import {
+ canBeCastAsNumberOrNull,
+ castAsNumberOrNull,
+} from '../cast-as-number-or-null';
+
+describe('canBeCastAsNumberOrNull', () => {
+ it(`should return true if null`, () => {
+ expect(canBeCastAsNumberOrNull(null)).toBeTruthy();
+ });
+
+ it(`should return true if number`, () => {
+ expect(canBeCastAsNumberOrNull(9)).toBeTruthy();
+ });
+
+ it(`should return true if empty string`, () => {
+ expect(canBeCastAsNumberOrNull('')).toBeTruthy();
+ });
+
+ it(`should return true if integer string`, () => {
+ expect(canBeCastAsNumberOrNull('9')).toBeTruthy();
+ });
+
+ it(`should return false if undefined`, () => {
+ expect(canBeCastAsNumberOrNull(undefined)).toBeFalsy();
+ });
+
+ it(`should return false if non numeric string`, () => {
+ expect(canBeCastAsNumberOrNull('9a')).toBeFalsy();
+ });
+
+ it(`should return false if non numeric string #2`, () => {
+ expect(canBeCastAsNumberOrNull('a9a')).toBeFalsy();
+ });
+
+ it(`should return true if float`, () => {
+ expect(canBeCastAsNumberOrNull(0.9)).toBeTruthy();
+ });
+
+ it(`should return true if float string`, () => {
+ expect(canBeCastAsNumberOrNull('0.9')).toBeTruthy();
+ });
+});
+
+describe('castAsNumberOrNull', () => {
+ it(`should cast null to null`, () => {
+ expect(castAsNumberOrNull(null)).toBe(null);
+ });
+
+ it(`should cast empty string to null`, () => {
+ expect(castAsNumberOrNull('')).toBe(null);
+ });
+
+ it(`should cast an integer to an integer`, () => {
+ expect(castAsNumberOrNull(9)).toBe(9);
+ });
+
+ it(`should cast an integer string to an integer`, () => {
+ expect(castAsNumberOrNull('9')).toBe(9);
+ });
+
+ it(`should throw if trying to cast a float string to an integer`, () => {
+ expect(castAsNumberOrNull('9.9')).toBe(9.9);
+ });
+
+ it(`should throw if trying to cast a non numeric string to an integer`, () => {
+ expect(() => castAsNumberOrNull('9.9a')).toThrow(Error);
+ });
+
+ it(`should throw if trying to cast an undefined to an integer`, () => {
+ expect(() => castAsNumberOrNull(undefined)).toThrow(Error);
+ });
+});
diff --git a/packages/twenty-front/src/utils/cast-as-integer-or-null.ts b/packages/twenty-front/src/utils/cast-as-number-or-null.ts
similarity index 82%
rename from packages/twenty-front/src/utils/cast-as-integer-or-null.ts
rename to packages/twenty-front/src/utils/cast-as-number-or-null.ts
index 5cca0021dead..ef06e5b5a33e 100644
--- a/packages/twenty-front/src/utils/cast-as-integer-or-null.ts
+++ b/packages/twenty-front/src/utils/cast-as-number-or-null.ts
@@ -4,7 +4,7 @@ import { logError } from './logError';
const DEBUG_MODE = false;
-export const canBeCastAsIntegerOrNull = (
+export const canBeCastAsNumberOrNull = (
probableNumberOrNull: string | undefined | number | null,
): probableNumberOrNull is number | null => {
if (probableNumberOrNull === undefined) {
@@ -16,7 +16,7 @@ export const canBeCastAsIntegerOrNull = (
if (isNumber(probableNumberOrNull)) {
if (DEBUG_MODE) logError('typeof probableNumberOrNull === "number"');
- return Number.isInteger(probableNumberOrNull);
+ return true;
}
if (isNull(probableNumberOrNull)) {
@@ -39,8 +39,8 @@ export const canBeCastAsIntegerOrNull = (
return false;
}
- if (Number.isInteger(stringAsNumber)) {
- if (DEBUG_MODE) logError('Number.isInteger(stringAsNumber)');
+ if (isNumber(stringAsNumber)) {
+ if (DEBUG_MODE) logError('isNumber(stringAsNumber)');
return true;
}
@@ -49,10 +49,10 @@ export const canBeCastAsIntegerOrNull = (
return false;
};
-export const castAsIntegerOrNull = (
+export const castAsNumberOrNull = (
probableNumberOrNull: string | undefined | number | null,
): number | null => {
- if (canBeCastAsIntegerOrNull(probableNumberOrNull) === false) {
+ if (canBeCastAsNumberOrNull(probableNumberOrNull) === false) {
throw new Error('Cannot cast to number or null');
}
diff --git a/packages/twenty-front/src/utils/format/number.ts b/packages/twenty-front/src/utils/format/number.ts
index 4937372d0cbd..a36cb6fffad8 100644
--- a/packages/twenty-front/src/utils/format/number.ts
+++ b/packages/twenty-front/src/utils/format/number.ts
@@ -1,2 +1,8 @@
-export const formatNumber = (value: number): string =>
- value.toLocaleString('en-US');
+export const DEFAULT_DECIMAL_VALUE = 0;
+
+export const formatNumber = (value: number, decimals?: number): string => {
+ return value.toLocaleString('en-US', {
+ minimumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
+ maximumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
+ });
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts
new file mode 100644
index 000000000000..81ede4ec4faa
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts
@@ -0,0 +1,48 @@
+import { Injectable } from '@nestjs/common';
+
+import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
+
+import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
+import {
+ FieldMetadataException,
+ FieldMetadataExceptionCode,
+} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
+
+@Injectable()
+export class FieldMetadataValidationService<
+ T extends FieldMetadataType | 'default' = 'default',
+> {
+ constructor() {}
+
+ validateSettingsOrThrow({
+ fieldType,
+ settings,
+ }: {
+ fieldType: FieldMetadataType;
+ settings: FieldMetadataSettings;
+ }) {
+ switch (fieldType) {
+ case FieldMetadataType.NUMBER:
+ this.validateNumberSettings(settings);
+ break;
+ default:
+ break;
+ }
+ }
+
+ private validateNumberSettings(settings: FieldMetadataSettings) {
+ if ('decimals' in settings) {
+ const { decimals } = settings;
+
+ if (
+ decimals !== undefined &&
+ (decimals < 0 || !Number.isInteger(decimals))
+ ) {
+ throw new FieldMetadataException(
+ `Decimals value "${decimals}" must be a positive integer`,
+ FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
+ );
+ }
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
index 377575ba9bfd..b6cb8e8ed1d7 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
@@ -12,6 +12,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
+import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator';
@@ -44,7 +45,11 @@ import { UpdateFieldInput } from './dtos/update-field.input';
TypeORMModule,
ActorModule,
],
- services: [IsFieldMetadataDefaultValue, FieldMetadataService],
+ services: [
+ IsFieldMetadataDefaultValue,
+ FieldMetadataService,
+ FieldMetadataValidationService,
+ ],
resolvers: [
{
EntityClass: FieldMetadataEntity,
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts
index ac2c5b1d438d..019cfcea7bdf 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts
@@ -56,6 +56,7 @@ import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
+import { FieldMetadataValidationService } from './field-metadata-validation.service';
import {
FieldMetadataEntity,
FieldMetadataType,
@@ -82,6 +83,7 @@ export class FieldMetadataService extends TypeOrmQueryService(
+ fieldMetadataInput.type,
fieldMetadataInput,
objectMetadata,
);
@@ -391,6 +394,7 @@ export class FieldMetadataService extends TypeOrmQueryService(
+ existingFieldMetadata.type,
fieldMetadataInput,
objectMetadata,
);
@@ -707,7 +711,11 @@ export class FieldMetadataService extends TypeOrmQueryService(fieldMetadataInput: T, objectMetadata: ObjectMetadataEntity): T {
+ >(
+ fieldMetadataType: FieldMetadataType,
+ fieldMetadataInput: T,
+ objectMetadata: ObjectMetadataEntity,
+ ): T {
if (fieldMetadataInput.name) {
try {
validateFieldNameValidityOrThrow(fieldMetadataInput.name);
@@ -748,6 +756,13 @@ export class FieldMetadataService extends TypeOrmQueryService