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:
committed by
GitHub
parent
e3ed574420
commit
97eff774bd
@ -51,6 +51,7 @@ export const queries = {
|
|||||||
${baseFields}
|
${baseFields}
|
||||||
defaultValue
|
defaultValue
|
||||||
options
|
options
|
||||||
|
settings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export type FieldMetadataItemOption = {
|
|||||||
|
|
||||||
export type FieldMetadataItem = Omit<
|
export type FieldMetadataItem = Omit<
|
||||||
Field,
|
Field,
|
||||||
'__typename' | 'defaultValue' | 'options' | 'settings' | 'relationDefinition'
|
'__typename' | 'defaultValue' | 'options' | 'relationDefinition'
|
||||||
> & {
|
> & {
|
||||||
__typename?: string;
|
__typename?: string;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
|
|||||||
@ -54,5 +54,6 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
|
|||||||
metadata: fieldDefintionMetadata,
|
metadata: fieldDefintionMetadata,
|
||||||
type: field.type,
|
type: field.type,
|
||||||
}),
|
}),
|
||||||
|
settings: field.settings,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -338,6 +338,7 @@ export const RecordBoardCard = ({
|
|||||||
metadata: fieldDefinition.metadata,
|
metadata: fieldDefinition.metadata,
|
||||||
type: fieldDefinition.type,
|
type: fieldDefinition.type,
|
||||||
}),
|
}),
|
||||||
|
settings: fieldDefinition.settings,
|
||||||
},
|
},
|
||||||
useUpdateRecord: useUpdateOneRecordHook,
|
useUpdateRecord: useUpdateOneRecordHook,
|
||||||
hotkeyScope: InlineCellHotkeyScope.InlineCell,
|
hotkeyScope: InlineCellHotkeyScope.InlineCell,
|
||||||
|
|||||||
@ -2,7 +2,11 @@ import { useNumberFieldDisplay } from '@/object-record/record-field/meta-types/h
|
|||||||
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
|
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
|
||||||
|
|
||||||
export const NumberFieldDisplay = () => {
|
export const NumberFieldDisplay = () => {
|
||||||
const { fieldValue } = useNumberFieldDisplay();
|
const { fieldValue, fieldDefinition } = useNumberFieldDisplay();
|
||||||
|
return (
|
||||||
return <NumberDisplay value={fieldValue} />;
|
<NumberDisplay
|
||||||
|
value={fieldValue}
|
||||||
|
decimals={fieldDefinition.settings?.decimals}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecor
|
|||||||
import { FieldNumberValue } from '@/object-record/record-field/types/FieldMetadata';
|
import { FieldNumberValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
canBeCastAsIntegerOrNull,
|
canBeCastAsNumberOrNull,
|
||||||
castAsIntegerOrNull,
|
castAsNumberOrNull,
|
||||||
} from '~/utils/cast-as-integer-or-null';
|
} from '~/utils/cast-as-number-or-null';
|
||||||
|
|
||||||
import { FieldContext } from '../../contexts/FieldContext';
|
import { FieldContext } from '../../contexts/FieldContext';
|
||||||
import { usePersistField } from '../../hooks/usePersistField';
|
import { usePersistField } from '../../hooks/usePersistField';
|
||||||
@ -32,11 +33,11 @@ export const useNumberField = () => {
|
|||||||
const persistField = usePersistField();
|
const persistField = usePersistField();
|
||||||
|
|
||||||
const persistNumberField = (newValue: string) => {
|
const persistNumberField = (newValue: string) => {
|
||||||
if (!canBeCastAsIntegerOrNull(newValue)) {
|
if (!canBeCastAsNumberOrNull(newValue)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const castedValue = castAsIntegerOrNull(newValue);
|
const castedValue = castAsNumberOrNull(newValue);
|
||||||
|
|
||||||
persistField(castedValue);
|
persistField(castedValue);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -16,4 +16,7 @@ export type FieldDefinition<T extends FieldMetadata> = {
|
|||||||
infoTooltipContent?: string;
|
infoTooltipContent?: string;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
editButtonIcon?: IconComponent;
|
editButtonIcon?: IconComponent;
|
||||||
|
settings?: {
|
||||||
|
decimals?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const numberFieldDefaultValueSchema = z.object({
|
||||||
|
decimals: z.number().nullable(),
|
||||||
|
});
|
||||||
@ -11,6 +11,8 @@ import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/
|
|||||||
import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
|
import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
|
||||||
import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
|
import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
|
||||||
import { SettingsDataModelFieldDateSettingsFormCard } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard';
|
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 { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm';
|
||||||
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard';
|
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard';
|
||||||
import {
|
import {
|
||||||
@ -52,6 +54,10 @@ const multiSelectFieldFormSchema = z
|
|||||||
.object({ type: z.literal(FieldMetadataType.MultiSelect) })
|
.object({ type: z.literal(FieldMetadataType.MultiSelect) })
|
||||||
.merge(settingsDataModelFieldMultiSelectFormSchema);
|
.merge(settingsDataModelFieldMultiSelectFormSchema);
|
||||||
|
|
||||||
|
const numberFieldFormSchema = z
|
||||||
|
.object({ type: z.literal(FieldMetadataType.Number) })
|
||||||
|
.merge(settingsDataModelFieldNumberFormSchema);
|
||||||
|
|
||||||
const otherFieldsFormSchema = z.object({
|
const otherFieldsFormSchema = z.object({
|
||||||
type: z.enum(
|
type: z.enum(
|
||||||
Object.keys(
|
Object.keys(
|
||||||
@ -63,6 +69,7 @@ const otherFieldsFormSchema = z.object({
|
|||||||
FieldMetadataType.MultiSelect,
|
FieldMetadataType.MultiSelect,
|
||||||
FieldMetadataType.Date,
|
FieldMetadataType.Date,
|
||||||
FieldMetadataType.DateTime,
|
FieldMetadataType.DateTime,
|
||||||
|
FieldMetadataType.Number,
|
||||||
]),
|
]),
|
||||||
) as [FieldMetadataType, ...FieldMetadataType[]],
|
) as [FieldMetadataType, ...FieldMetadataType[]],
|
||||||
),
|
),
|
||||||
@ -78,13 +85,17 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
|
|||||||
relationFieldFormSchema,
|
relationFieldFormSchema,
|
||||||
selectFieldFormSchema,
|
selectFieldFormSchema,
|
||||||
multiSelectFieldFormSchema,
|
multiSelectFieldFormSchema,
|
||||||
|
numberFieldFormSchema,
|
||||||
otherFieldsFormSchema,
|
otherFieldsFormSchema,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
type SettingsDataModelFieldSettingsFormCardProps = {
|
type SettingsDataModelFieldSettingsFormCardProps = {
|
||||||
isCreatingField?: boolean;
|
isCreatingField?: boolean;
|
||||||
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> &
|
fieldMetadataItem: Pick<
|
||||||
|
FieldMetadataItem,
|
||||||
|
'icon' | 'label' | 'type' | 'isCustom'
|
||||||
|
> &
|
||||||
Partial<Omit<FieldMetadataItem, 'icon' | 'label' | 'type'>>;
|
Partial<Omit<FieldMetadataItem, 'icon' | 'label' | 'type'>>;
|
||||||
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
|
} & 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 (
|
if (
|
||||||
fieldMetadataItem.type === FieldMetadataType.Select ||
|
fieldMetadataItem.type === FieldMetadataType.Select ||
|
||||||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
|
fieldMetadataItem.type === FieldMetadataType.MultiSelect
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,8 +4,11 @@ import { EllipsisDisplay } from './EllipsisDisplay';
|
|||||||
|
|
||||||
type NumberDisplayProps = {
|
type NumberDisplayProps = {
|
||||||
value: string | number | null | undefined;
|
value: string | number | null | undefined;
|
||||||
|
decimals?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NumberDisplay = ({ value }: NumberDisplayProps) => (
|
export const NumberDisplay = ({ value, decimals }: NumberDisplayProps) => (
|
||||||
<EllipsisDisplay>{value && formatNumber(Number(value))}</EllipsisDisplay>
|
<EllipsisDisplay>
|
||||||
|
{value && formatNumber(Number(value), decimals)}
|
||||||
|
</EllipsisDisplay>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -50,6 +50,7 @@ export const mapViewFieldsToColumnDefinitions = ({
|
|||||||
isSortable: correspondingColumnDefinition.isSortable,
|
isSortable: correspondingColumnDefinition.isSortable,
|
||||||
isFilterable: correspondingColumnDefinition.isFilterable,
|
isFilterable: correspondingColumnDefinition.isFilterable,
|
||||||
defaultValue: correspondingColumnDefinition.defaultValue,
|
defaultValue: correspondingColumnDefinition.defaultValue,
|
||||||
|
settings: correspondingColumnDefinition.settings,
|
||||||
} as ColumnDefinition<FieldMetadata>;
|
} as ColumnDefinition<FieldMetadata>;
|
||||||
})
|
})
|
||||||
.filter(isDefined);
|
.filter(isDefined);
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,7 +4,7 @@ import { logError } from './logError';
|
|||||||
|
|
||||||
const DEBUG_MODE = false;
|
const DEBUG_MODE = false;
|
||||||
|
|
||||||
export const canBeCastAsIntegerOrNull = (
|
export const canBeCastAsNumberOrNull = (
|
||||||
probableNumberOrNull: string | undefined | number | null,
|
probableNumberOrNull: string | undefined | number | null,
|
||||||
): probableNumberOrNull is number | null => {
|
): probableNumberOrNull is number | null => {
|
||||||
if (probableNumberOrNull === undefined) {
|
if (probableNumberOrNull === undefined) {
|
||||||
@ -16,7 +16,7 @@ export const canBeCastAsIntegerOrNull = (
|
|||||||
if (isNumber(probableNumberOrNull)) {
|
if (isNumber(probableNumberOrNull)) {
|
||||||
if (DEBUG_MODE) logError('typeof probableNumberOrNull === "number"');
|
if (DEBUG_MODE) logError('typeof probableNumberOrNull === "number"');
|
||||||
|
|
||||||
return Number.isInteger(probableNumberOrNull);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNull(probableNumberOrNull)) {
|
if (isNull(probableNumberOrNull)) {
|
||||||
@ -39,8 +39,8 @@ export const canBeCastAsIntegerOrNull = (
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (Number.isInteger(stringAsNumber)) {
|
if (isNumber(stringAsNumber)) {
|
||||||
if (DEBUG_MODE) logError('Number.isInteger(stringAsNumber)');
|
if (DEBUG_MODE) logError('isNumber(stringAsNumber)');
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -49,10 +49,10 @@ export const canBeCastAsIntegerOrNull = (
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const castAsIntegerOrNull = (
|
export const castAsNumberOrNull = (
|
||||||
probableNumberOrNull: string | undefined | number | null,
|
probableNumberOrNull: string | undefined | number | null,
|
||||||
): number | null => {
|
): number | null => {
|
||||||
if (canBeCastAsIntegerOrNull(probableNumberOrNull) === false) {
|
if (canBeCastAsNumberOrNull(probableNumberOrNull) === false) {
|
||||||
throw new Error('Cannot cast to number or null');
|
throw new Error('Cannot cast to number or null');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,2 +1,8 @@
|
|||||||
export const formatNumber = (value: number): string =>
|
export const DEFAULT_DECIMAL_VALUE = 0;
|
||||||
value.toLocaleString('en-US');
|
|
||||||
|
export const formatNumber = (value: number, decimals?: number): string => {
|
||||||
|
return value.toLocaleString('en-US', {
|
||||||
|
minimumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
|
||||||
|
maximumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -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<T>;
|
||||||
|
}) {
|
||||||
|
switch (fieldType) {
|
||||||
|
case FieldMetadataType.NUMBER:
|
||||||
|
this.validateNumberSettings(settings);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateNumberSettings(settings: FieldMetadataSettings<T>) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
|||||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
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 { 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 { 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 { 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';
|
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,
|
TypeORMModule,
|
||||||
ActorModule,
|
ActorModule,
|
||||||
],
|
],
|
||||||
services: [IsFieldMetadataDefaultValue, FieldMetadataService],
|
services: [
|
||||||
|
IsFieldMetadataDefaultValue,
|
||||||
|
FieldMetadataService,
|
||||||
|
FieldMetadataValidationService,
|
||||||
|
],
|
||||||
resolvers: [
|
resolvers: [
|
||||||
{
|
{
|
||||||
EntityClass: FieldMetadataEntity,
|
EntityClass: FieldMetadataEntity,
|
||||||
|
|||||||
@ -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 { 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 { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
|
||||||
|
|
||||||
|
import { FieldMetadataValidationService } from './field-metadata-validation.service';
|
||||||
import {
|
import {
|
||||||
FieldMetadataEntity,
|
FieldMetadataEntity,
|
||||||
FieldMetadataType,
|
FieldMetadataType,
|
||||||
@ -82,6 +83,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
private readonly typeORMService: TypeORMService,
|
private readonly typeORMService: TypeORMService,
|
||||||
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
||||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||||
|
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
|
||||||
) {
|
) {
|
||||||
super(fieldMetadataRepository);
|
super(fieldMetadataRepository);
|
||||||
}
|
}
|
||||||
@ -157,6 +159,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.validateFieldMetadataInput<CreateFieldInput>(
|
this.validateFieldMetadataInput<CreateFieldInput>(
|
||||||
|
fieldMetadataInput.type,
|
||||||
fieldMetadataInput,
|
fieldMetadataInput,
|
||||||
objectMetadata,
|
objectMetadata,
|
||||||
);
|
);
|
||||||
@ -391,6 +394,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.validateFieldMetadataInput<UpdateFieldInput>(
|
this.validateFieldMetadataInput<UpdateFieldInput>(
|
||||||
|
existingFieldMetadata.type,
|
||||||
fieldMetadataInput,
|
fieldMetadataInput,
|
||||||
objectMetadata,
|
objectMetadata,
|
||||||
);
|
);
|
||||||
@ -707,7 +711,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
|
|
||||||
private validateFieldMetadataInput<
|
private validateFieldMetadataInput<
|
||||||
T extends UpdateFieldInput | CreateFieldInput,
|
T extends UpdateFieldInput | CreateFieldInput,
|
||||||
>(fieldMetadataInput: T, objectMetadata: ObjectMetadataEntity): T {
|
>(
|
||||||
|
fieldMetadataType: FieldMetadataType,
|
||||||
|
fieldMetadataInput: T,
|
||||||
|
objectMetadata: ObjectMetadataEntity,
|
||||||
|
): T {
|
||||||
if (fieldMetadataInput.name) {
|
if (fieldMetadataInput.name) {
|
||||||
try {
|
try {
|
||||||
validateFieldNameValidityOrThrow(fieldMetadataInput.name);
|
validateFieldNameValidityOrThrow(fieldMetadataInput.name);
|
||||||
@ -748,6 +756,13 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fieldMetadataInput.settings) {
|
||||||
|
this.fieldMetadataValidationService.validateSettingsOrThrow({
|
||||||
|
fieldType: fieldMetadataType,
|
||||||
|
settings: fieldMetadataInput.settings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return fieldMetadataInput;
|
return fieldMetadataInput;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ type FieldMetadataDefaultSettings = {
|
|||||||
|
|
||||||
type FieldMetadataNumberSettings = {
|
type FieldMetadataNumberSettings = {
|
||||||
dataType: NumberDataType;
|
dataType: NumberDataType;
|
||||||
|
decimals?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FieldMetadataDateSettings = {
|
type FieldMetadataDateSettings = {
|
||||||
|
|||||||
Reference in New Issue
Block a user