From b3d639c96445f38cc4e1b29ad4ee4b4baeb4e2f3 Mon Sep 17 00:00:00 2001 From: gitstart-twenty Date: Mon, 30 Sep 2024 12:57:38 +0000 Subject: [PATCH 1/4] Allow input and display of floats for Number fields --- .../object-metadata/graphql/mutations.ts | 1 + .../object-metadata/graphql/queries.ts | 1 + .../hooks/__mocks__/useFieldMetadataItem.ts | 1 + .../types/FieldMetadataItem.ts | 2 +- ...ormatFieldMetadataItemAsFieldDefinition.ts | 1 + .../components/RecordBoardCard.tsx | 1 + .../display/components/NumberFieldDisplay.tsx | 10 +- .../meta-types/hooks/useNumberField.ts | 11 +- .../record-field/types/FieldDefinition.ts | 3 + .../numberFieldDefaultValueSchema.ts | 5 + ...SettingsDataModelFieldSettingsFormCard.tsx | 17 ++ ...ttingsDataModelFieldNumberDecimalInput.tsx | 159 ++++++++++++++++++ .../SettingsDataModelFieldNumberForm.tsx | 51 ++++++ ...gsDataModelFieldNumberSettingsFormCard.tsx | 45 +++++ .../display/components/NumberDisplay.tsx | 7 +- .../utils/mapViewFieldsToColumnDefinitions.ts | 1 + .../__tests__/cast-as-integer-or-null.test.ts | 72 -------- .../__tests__/cast-as-number-or-null.test.ts | 72 ++++++++ ...r-or-null.ts => cast-as-number-or-null.ts} | 12 +- .../twenty-front/src/utils/format/number.ts | 6 +- .../field-metadata-settings.interface.ts | 1 + 21 files changed, 388 insertions(+), 91 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx delete mode 100644 packages/twenty-front/src/utils/__tests__/cast-as-integer-or-null.test.ts create mode 100644 packages/twenty-front/src/utils/__tests__/cast-as-number-or-null.test.ts rename packages/twenty-front/src/utils/{cast-as-integer-or-null.ts => cast-as-number-or-null.ts} (82%) diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts b/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts index 80814a64b7ec..5bd821244c49 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts @@ -38,6 +38,7 @@ export const CREATE_ONE_FIELD_METADATA_ITEM = gql` settings defaultValue options + settings } } `; diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts index a61811431d2f..e38ebe85b7aa 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts @@ -40,6 +40,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` createdAt updatedAt defaultValue + settings options settings relationDefinition { 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..11225e00f4f7 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,6 +85,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion( relationFieldFormSchema, selectFieldFormSchema, multiSelectFieldFormSchema, + numberFieldFormSchema, otherFieldsFormSchema, ], ); @@ -163,6 +171,15 @@ 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..9e08489608a9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx @@ -0,0 +1,159 @@ +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; +}; + +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: 0px; + border-radius: 4px; + 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, +}: 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 > MAX_VALUE) { + onChange(MAX_VALUE); + return; + } + onChange(castedNumber); + }; + return ( + <> + Number of decimals + + + Example: {exampleValue} + + + + handleTextInputChange(value)} + /> + + + + + + ); +}; 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..3b88cca415df --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx @@ -0,0 +1,51 @@ +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'; + +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 })} + > + ); + }} + /> + + ); +}; 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..0732c9357aab 100644 --- a/packages/twenty-front/src/utils/format/number.ts +++ b/packages/twenty-front/src/utils/format/number.ts @@ -1,2 +1,4 @@ -export const formatNumber = (value: number): string => - value.toLocaleString('en-US'); +export const formatNumber = (value: number, decimals?: number): string => + decimals !== undefined + ? value.toFixed(decimals) + : value.toLocaleString('en-US'); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts index 7fa4f39c09f8..e0d40c0f7ccf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts @@ -12,6 +12,7 @@ type FieldMetadataDefaultSettings = { type FieldMetadataNumberSettings = { dataType: NumberDataType; + decimals?: number; }; type FieldMetadataDateSettings = { From 7313a85bf658b612abcdc060494220f6c7aaabc1 Mon Sep 17 00:00:00 2001 From: gitstart-twenty Date: Wed, 2 Oct 2024 19:46:28 +0000 Subject: [PATCH 2/4] add backend validation and rafactors --- .../object-metadata/graphql/mutations.ts | 1 - .../object-metadata/graphql/queries.ts | 1 - ...SettingsDataModelFieldSettingsFormCard.tsx | 6 +++- ...ttingsDataModelFieldNumberDecimalInput.tsx | 13 ++++++-- .../SettingsDataModelFieldNumberForm.tsx | 8 +++-- .../twenty-front/src/utils/format/number.ts | 9 +++--- .../field-metadata-validation.service.ts | 32 +++++++++++++++++++ .../field-metadata/field-metadata.module.ts | 7 +++- .../field-metadata/field-metadata.service.ts | 8 +++++ 9 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts b/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts index 5bd821244c49..80814a64b7ec 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts @@ -38,7 +38,6 @@ export const CREATE_ONE_FIELD_METADATA_ITEM = gql` settings defaultValue options - settings } } `; diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts index e38ebe85b7aa..a61811431d2f 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts @@ -40,7 +40,6 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` createdAt updatedAt defaultValue - settings options settings relationDefinition { 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 11225e00f4f7..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 @@ -92,7 +92,10 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion( type SettingsDataModelFieldSettingsFormCardProps = { isCreatingField?: boolean; - fieldMetadataItem: Pick & + fieldMetadataItem: Pick< + FieldMetadataItem, + 'icon' | 'label' | 'type' | 'isCustom' + > & Partial>; } & Pick; @@ -174,6 +177,7 @@ export const SettingsDataModelFieldSettingsFormCard = ({ if (fieldMetadataItem.type === FieldMetadataType.Number) { return ( 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 index 9e08489608a9..706bcea37154 100644 --- 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 @@ -8,6 +8,7 @@ import { castAsNumberOrNull } from '~/utils/cast-as-number-or-null'; type SettingsDataModelFieldNumberDecimalsInputProps = { value: number; onChange: (value: number) => void; + disabled?: boolean; }; const StyledCounterContainer = styled.div` @@ -56,8 +57,8 @@ const StyledTextInput = styled(TextInput)` background: ${({ theme }) => theme.background.noisy}; } input ~ div { - padding-right: 0px; - border-radius: 4px; + padding-right: ${({ theme }) => theme.spacing(0)}; + border-radius: ${({ theme }) => theme.spacing(1)}; background: ${({ theme }) => theme.background.noisy}; } `; @@ -97,6 +98,7 @@ const MAX_VALUE = 100; export const SettingsDataModelFieldNumberDecimalsInput = ({ value, onChange, + disabled, }: SettingsDataModelFieldNumberDecimalsInputProps) => { const exampleValue = (1000).toFixed(value); @@ -121,6 +123,10 @@ export const SettingsDataModelFieldNumberDecimalsInput = ({ return; } + if (castedNumber < MIN_VALUE) { + return; + } + if (castedNumber > MAX_VALUE) { onChange(MAX_VALUE); return; @@ -139,17 +145,20 @@ export const SettingsDataModelFieldNumberDecimalsInput = ({ variant="secondary" onClick={handleDecrementCounter} Icon={IconMinus} + disabled={disabled} /> 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 index 3b88cca415df..3a80bf0e6104 100644 --- 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 @@ -5,6 +5,7 @@ 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, @@ -31,9 +32,11 @@ export const SettingsDataModelFieldNumberForm = ({ return ( { const count = value?.decimals ?? 0; @@ -42,6 +45,7 @@ export const SettingsDataModelFieldNumberForm = ({ onChange({ decimals: value })} + disabled={disabled} > ); }} diff --git a/packages/twenty-front/src/utils/format/number.ts b/packages/twenty-front/src/utils/format/number.ts index 0732c9357aab..3446bc045951 100644 --- a/packages/twenty-front/src/utils/format/number.ts +++ b/packages/twenty-front/src/utils/format/number.ts @@ -1,4 +1,5 @@ -export const formatNumber = (value: number, decimals?: number): string => - decimals !== undefined - ? value.toFixed(decimals) - : value.toLocaleString('en-US'); +export const DEFAULT_DECIMAL_VALUE = 0; + +export const formatNumber = (value: number, decimals?: number): string => { + return value.toFixed(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..a50ee4c6ad63 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts @@ -0,0 +1,32 @@ +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(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..bf0e75221748 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 Date: Thu, 3 Oct 2024 18:29:48 +0200 Subject: [PATCH 3/4] refactor and fix number display as locale --- .../twenty-front/src/utils/format/number.ts | 5 ++++- .../field-metadata-validation.service.ts | 18 +++++++++++++++++- .../field-metadata/field-metadata.service.ts | 15 +++++++++++---- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/twenty-front/src/utils/format/number.ts b/packages/twenty-front/src/utils/format/number.ts index 3446bc045951..330b914f16fe 100644 --- a/packages/twenty-front/src/utils/format/number.ts +++ b/packages/twenty-front/src/utils/format/number.ts @@ -1,5 +1,8 @@ export const DEFAULT_DECIMAL_VALUE = 0; export const formatNumber = (value: number, decimals?: number): string => { - return value.toFixed(decimals ?? DEFAULT_DECIMAL_VALUE); + return value.toLocaleString('en-US', { + minimumFractionDigits: 0, + 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 index a50ee4c6ad63..81ede4ec4faa 100644 --- 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 @@ -14,7 +14,23 @@ export class FieldMetadataValidationService< > { constructor() {} - validateSettingsOrThrow(settings: FieldMetadataSettings) { + 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; 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 bf0e75221748..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 @@ -159,6 +159,7 @@ export class FieldMetadataService extends TypeOrmQueryService( + fieldMetadataInput.type, fieldMetadataInput, objectMetadata, ); @@ -393,6 +394,7 @@ export class FieldMetadataService extends TypeOrmQueryService( + existingFieldMetadata.type, fieldMetadataInput, objectMetadata, ); @@ -709,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); @@ -751,9 +757,10 @@ export class FieldMetadataService extends TypeOrmQueryService Date: Thu, 3 Oct 2024 18:56:56 +0200 Subject: [PATCH 4/4] Always show decimals --- packages/twenty-front/src/utils/format/number.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/twenty-front/src/utils/format/number.ts b/packages/twenty-front/src/utils/format/number.ts index 330b914f16fe..a36cb6fffad8 100644 --- a/packages/twenty-front/src/utils/format/number.ts +++ b/packages/twenty-front/src/utils/format/number.ts @@ -2,7 +2,7 @@ export const DEFAULT_DECIMAL_VALUE = 0; export const formatNumber = (value: number, decimals?: number): string => { return value.toLocaleString('en-US', { - minimumFractionDigits: 0, + minimumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE, maximumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE, }); };