Allow input and display of floats for Number fields (#7340)

### Description

- We added a decimal field for a Number Field type in the settings
- We updated the Number Field type create a form with decimals input
- We are not implementing the dropdown present on the Figma because it
seems not related

### Demo


<https://www.loom.com/share/18a8d4b712a14f6d8b66806764f8467f?sid=3fc79b46-ae32-46e3-8635-d0eee02e53b2>

Fixes #6987

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
This commit is contained in:
gitstart-app[bot]
2024-10-04 10:45:25 +02:00
committed by GitHub
parent e3ed574420
commit 97eff774bd
22 changed files with 478 additions and 94 deletions

View File

@ -51,6 +51,7 @@ export const queries = {
${baseFields}
defaultValue
options
settings
}
}
`,

View File

@ -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;

View File

@ -54,5 +54,6 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
metadata: fieldDefintionMetadata,
type: field.type,
}),
settings: field.settings,
};
};

View File

@ -338,6 +338,7 @@ export const RecordBoardCard = ({
metadata: fieldDefinition.metadata,
type: fieldDefinition.type,
}),
settings: fieldDefinition.settings,
},
useUpdateRecord: useUpdateOneRecordHook,
hotkeyScope: InlineCellHotkeyScope.InlineCell,

View File

@ -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 <NumberDisplay value={fieldValue} />;
const { fieldValue, fieldDefinition } = useNumberFieldDisplay();
return (
<NumberDisplay
value={fieldValue}
decimals={fieldDefinition.settings?.decimals}
/>
);
};

View File

@ -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);
};

View File

@ -16,4 +16,7 @@ export type FieldDefinition<T extends FieldMetadata> = {
infoTooltipContent?: string;
defaultValue?: any;
editButtonIcon?: IconComponent;
settings?: {
decimals?: number;
};
};

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
export const numberFieldDefaultValueSchema = z.object({
decimals: z.number().nullable(),
});

View File

@ -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, 'icon' | 'label' | 'type'> &
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'isCustom'
> &
Partial<Omit<FieldMetadataItem, 'icon' | 'label' | 'type'>>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
@ -163,6 +174,16 @@ export const SettingsDataModelFieldSettingsFormCard = ({
);
}
if (fieldMetadataItem.type === FieldMetadataType.Number) {
return (
<SettingsDataModelFieldNumberSettingsFormCard
disabled={fieldMetadataItem.isCustom === false}
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
);
}
if (
fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect

View File

@ -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 (
<>
<StyledTitle>Number of decimals</StyledTitle>
<StyledCounterContainer>
<StyledCounterInnerContainer>
<StyledExampleText>Example: {exampleValue}</StyledExampleText>
<StyledCounterControlsIcons>
<StyledInfoButton variant="tertiary" Icon={IconInfoCircle} />
<StyledControlButton
variant="secondary"
onClick={handleDecrementCounter}
Icon={IconMinus}
disabled={disabled}
/>
<StyledTextInput
name="decimals"
fullWidth
value={value.toString()}
onChange={(value) => handleTextInputChange(value)}
disabled={disabled}
/>
<StyledControlButton
variant="secondary"
onClick={handleIncrementCounter}
Icon={IconPlus}
disabled={disabled}
/>
</StyledCounterControlsIcons>
</StyledCounterInnerContainer>
</StyledCounterContainer>
</>
);
};

View File

@ -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<SettingsDataModelFieldNumberFormValues>();
return (
<CardContent>
<Controller
name="settings"
defaultValue={{
decimals:
fieldMetadataItem?.settings?.decimals ?? DEFAULT_DECIMAL_VALUE,
}}
control={control}
render={({ field: { onChange, value } }) => {
const count = value?.decimals ?? 0;
return (
<SettingsDataModelFieldNumberDecimalsInput
value={count}
onChange={(value) => onChange({ decimals: value })}
disabled={disabled}
></SettingsDataModelFieldNumberDecimalsInput>
);
}}
/>
</CardContent>
);
};

View File

@ -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<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
export const SettingsDataModelFieldNumberSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
}: SettingsDataModelFieldNumberSettingsFormCardProps) => {
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
}
form={
<SettingsDataModelFieldNumberForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
}
/>
);
};

View File

@ -4,8 +4,11 @@ import { EllipsisDisplay } from './EllipsisDisplay';
type NumberDisplayProps = {
value: string | number | null | undefined;
decimals?: number;
};
export const NumberDisplay = ({ value }: NumberDisplayProps) => (
<EllipsisDisplay>{value && formatNumber(Number(value))}</EllipsisDisplay>
export const NumberDisplay = ({ value, decimals }: NumberDisplayProps) => (
<EllipsisDisplay>
{value && formatNumber(Number(value), decimals)}
</EllipsisDisplay>
);

View File

@ -50,6 +50,7 @@ export const mapViewFieldsToColumnDefinitions = ({
isSortable: correspondingColumnDefinition.isSortable,
isFilterable: correspondingColumnDefinition.isFilterable,
defaultValue: correspondingColumnDefinition.defaultValue,
settings: correspondingColumnDefinition.settings,
} as ColumnDefinition<FieldMetadata>;
})
.filter(isDefined);