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