Use defaultValue in currency input (#4911)

- Fix default value sent to backend, using single quotes by default
- Use default value in field definition and column definition so that
field inputs can access it
- Used currency default value in CurrencyFieldInput

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2024-04-11 16:49:00 +02:00
committed by GitHub
parent e48960afbe
commit c69a3f01da
17 changed files with 188 additions and 103 deletions

View File

@ -1,6 +1,7 @@
import { v4 } from 'uuid';
import { FieldMetadataOption } from '@/object-metadata/types/FieldMetadataOption.ts';
import { getDefaultValueForBackend } from '@/object-metadata/utils/getDefaultValueForBackend';
import { Field } from '~/generated/graphql';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
@ -24,11 +25,11 @@ export const useFieldMetadataItem = () => {
},
) => {
const formattedInput = formatFieldMetadataItemInput(input);
const defaultValue = input.defaultValue
? typeof input.defaultValue == 'string'
? `'${input.defaultValue}'`
: input.defaultValue
: formattedInput.defaultValue ?? undefined;
const defaultValue = getDefaultValueForBackend(
input.defaultValue ?? formattedInput.defaultValue,
input.type,
);
return createOneFieldMetadataItem({
...formattedInput,

View File

@ -43,5 +43,6 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
options: field.options,
},
iconName: field.icon ?? 'Icon123',
defaultValue: field.defaultValue,
};
};

View File

@ -0,0 +1,19 @@
import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getDefaultValueForBackend = (
defaultValue: any,
fieldMetadataType: FieldMetadataType,
) => {
if (fieldMetadataType === FieldMetadataType.Currency) {
const currencyDefaultValue = defaultValue as FieldCurrencyValue;
return {
amountMicros: currencyDefaultValue.amountMicros,
currencyCode: `'${currencyDefaultValue.currencyCode}'` as any,
} satisfies FieldCurrencyValue;
} else if (typeof defaultValue === 'string') {
return `'${defaultValue}'`;
}
return defaultValue;
};

View File

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

View File

@ -64,6 +64,8 @@ export const useCurrencyField = () => {
const draftValue = useRecoilValue(getDraftValueSelector());
const defaultValue = fieldDefinition.defaultValue;
return {
fieldDefinition,
fieldValue,
@ -72,5 +74,6 @@ export const useCurrencyField = () => {
setFieldValue,
hotkeyScope,
persistCurrencyField,
defaultValue,
};
};

View File

@ -1,4 +1,5 @@
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata';
import { CurrencyInput } from '@/ui/field/input/components/CurrencyInput';
import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
@ -21,14 +22,27 @@ export const CurrencyFieldInput = ({
onTab,
onShiftTab,
}: CurrencyFieldInputProps) => {
const { hotkeyScope, draftValue, persistCurrencyField, setDraftValue } =
useCurrencyField();
const {
hotkeyScope,
draftValue,
persistCurrencyField,
setDraftValue,
defaultValue,
} = useCurrencyField();
const currencyCode =
draftValue?.currencyCode ??
((defaultValue as FieldCurrencyValue).currencyCode.replace(
/'/g,
'',
) as CurrencyCode) ??
CurrencyCode.USD;
const handleEnter = (newValue: string) => {
onEnter?.(() => {
persistCurrencyField({
amountText: newValue,
currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD,
currencyCode,
});
});
};
@ -37,7 +51,7 @@ export const CurrencyFieldInput = ({
onEscape?.(() => {
persistCurrencyField({
amountText: newValue,
currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD,
currencyCode,
});
});
};
@ -49,7 +63,7 @@ export const CurrencyFieldInput = ({
onClickOutside?.(() => {
persistCurrencyField({
amountText: newValue,
currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD,
currencyCode,
});
});
};
@ -58,7 +72,7 @@ export const CurrencyFieldInput = ({
onTab?.(() => {
persistCurrencyField({
amountText: newValue,
currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD,
currencyCode,
});
});
};
@ -67,7 +81,7 @@ export const CurrencyFieldInput = ({
onShiftTab?.(() =>
persistCurrencyField({
amountText: newValue,
currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD,
currencyCode,
}),
);
};
@ -75,7 +89,7 @@ export const CurrencyFieldInput = ({
const handleChange = (newValue: string) => {
setDraftValue({
amount: newValue,
currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD,
currencyCode,
});
};
@ -90,7 +104,7 @@ export const CurrencyFieldInput = ({
<FieldInputOverlay>
<CurrencyInput
value={draftValue?.amount?.toString() ?? ''}
currencyCode={draftValue?.currencyCode ?? CurrencyCode.USD}
currencyCode={currencyCode}
autoFocus
placeholder="Currency"
onClickOutside={handleClickOutside}

View File

@ -23,4 +23,5 @@ export type FieldDefinition<T extends FieldMetadata> = {
type: FieldMetadataType;
metadata: T;
infoTooltipContent?: string;
defaultValue: any;
};

View File

@ -155,6 +155,8 @@ export const RecordShowContainer = ({
labelIdentifierFieldMetadataItem?.name || '',
objectMetadataNameSingular: objectNameSingular,
},
defaultValue:
labelIdentifierFieldMetadataItem?.defaultValue,
},
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,

View File

@ -15,7 +15,10 @@ import { useIcons } from '@/ui/display/icon/hooks/useIcons';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsDataModelFieldPreviewProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> & {
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue'
> & {
id?: string;
name?: string;
};
@ -106,6 +109,7 @@ export const SettingsDataModelFieldPreview = ({
relationObjectMetadataItem?.nameSingular,
options: selectOptions,
},
defaultValue: fieldMetadataItem.defaultValue,
},
hotkeyScope: 'field-preview',
}}

View File

@ -20,6 +20,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconLink',
isVisible: true,
defaultValue: '',
},
{
position: 1,
@ -36,6 +37,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconUsers',
isVisible: true,
defaultValue: 0,
},
{
position: 2,
@ -52,6 +54,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconBuildingSkyscraper',
isVisible: true,
defaultValue: '',
},
{
position: 3,
@ -69,6 +72,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconHeart',
isVisible: true,
defaultValue: [],
},
{
position: 4,
@ -85,6 +89,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconMap',
isVisible: true,
defaultValue: '',
},
{
position: 5,
@ -102,6 +107,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconUserCircle',
isVisible: true,
defaultValue: null,
},
{
position: 6,
@ -119,6 +125,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconUsers',
isVisible: true,
defaultValue: [],
},
{
position: 7,
@ -136,6 +143,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconFileImport',
isVisible: true,
defaultValue: [],
},
{
position: 8,
@ -152,6 +160,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconCalendar',
isVisible: true,
defaultValue: '',
},
{
position: 9,
@ -168,6 +177,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconTarget',
isVisible: true,
defaultValue: false,
},
{
position: 10,
@ -184,6 +194,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconBrandLinkedin',
isVisible: true,
defaultValue: '',
},
{
position: 11,
@ -201,6 +212,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconTargetArrow',
isVisible: true,
defaultValue: [],
},
{
position: 12,
@ -217,6 +229,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconBrandX',
isVisible: true,
defaultValue: '',
},
{
position: 13,
@ -234,6 +247,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconCheckbox',
isVisible: true,
defaultValue: [],
},
{
position: 14,
@ -250,6 +264,7 @@ export const SIGN_IN_BACKGROUND_MOCK_COLUMN_DEFINITIONS = (
},
iconName: 'IconMoneybag',
isVisible: true,
defaultValue: 0,
},
] satisfies ColumnDefinition<FieldMetadata>[]
).filter(filterAvailableTableColumns);

View File

@ -7,7 +7,6 @@ import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/S
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { CurrencyPickerDropdownButton } from '@/ui/input/components/internal/currency/components/CurrencyPickerDropdownButton';
import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle';
import { isDefined } from '~/utils/isDefined';
export const StyledInput = styled.input`
margin: 0;
@ -77,9 +76,6 @@ export const CurrencyInput = ({
const theme = useTheme();
const [internalText, setInternalText] = useState(value);
const [internalCurrency, setInternalCurrency] = useState<Currency | null>(
null,
);
const wrapperRef = useRef<HTMLInputElement>(null);
@ -89,7 +85,6 @@ export const CurrencyInput = ({
};
const handleCurrencyChange = (currency: Currency) => {
setInternalCurrency(currency);
onSelect?.(currency.value);
};
@ -116,23 +111,18 @@ export const CurrencyInput = ({
[],
);
useEffect(() => {
const currency = currencies.find(({ value }) => value === currencyCode);
if (isDefined(currency)) {
setInternalCurrency(currency);
}
}, [currencies, currencyCode]);
const currency = currencies.find(({ value }) => value === currencyCode);
useEffect(() => {
setInternalText(value);
}, [value]);
const Icon: IconComponent = internalCurrency?.Icon;
const Icon: IconComponent = currency?.Icon;
return (
<StyledContainer ref={wrapperRef}>
<CurrencyPickerDropdownButton
valueCode={internalCurrency?.value ?? ''}
valueCode={currency?.value ?? ''}
onChange={handleCurrencyChange}
currencies={currencies}
/>

View File

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronDown } from 'twenty-ui';
@ -6,7 +5,6 @@ import { IconChevronDown } from 'twenty-ui';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { isDefined } from '~/utils/isDefined';
import { CurrencyPickerHotkeyScope } from '../types/CurrencyPickerHotkeyScope';
@ -64,8 +62,6 @@ export const CurrencyPickerDropdownButton = ({
}) => {
const theme = useTheme();
const [selectedCurrency, setSelectedCurrency] = useState<Currency>();
const { isDropdownOpen, closeDropdown } = useDropdown(
CurrencyPickerHotkeyScope.CurrencyPicker,
);
@ -75,12 +71,9 @@ export const CurrencyPickerDropdownButton = ({
closeDropdown();
};
useEffect(() => {
const currency = currencies.find(({ value }) => value === valueCode);
if (isDefined(currency)) {
setSelectedCurrency(currency);
}
}, [valueCode, currencies]);
const currency = currencies.find(({ value }) => value === valueCode);
const currencyCode = currency?.value ?? CurrencyCode.USD;
return (
<Dropdown
@ -90,7 +83,7 @@ export const CurrencyPickerDropdownButton = ({
clickableComponent={
<StyledDropdownButtonContainer isUnfolded={isDropdownOpen}>
<StyledIconContainer>
{selectedCurrency ? selectedCurrency.value : CurrencyCode.USD}
{currencyCode}
<IconChevronDown size={theme.icon.size.sm} />
</StyledIconContainer>
</StyledDropdownButtonContainer>
@ -98,7 +91,7 @@ export const CurrencyPickerDropdownButton = ({
dropdownComponents={
<CurrencyPickerDropdownSelect
currencies={currencies}
selectedCurrency={selectedCurrency}
selectedCurrency={currency}
onChange={handleChange}
/>
}

View File

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

View File

@ -280,7 +280,6 @@ export const SettingsObjectNewFieldStep2 = () => {
};
const excludedFieldTypes: SettingsSupportedFieldType[] = [
FieldMetadataType.Currency,
FieldMetadataType.Email,
FieldMetadataType.FullName,
FieldMetadataType.Link,

View File

@ -3,104 +3,103 @@ import { validateDefaultValueForType } from 'src/engine/metadata-modules/field-m
describe('validateDefaultValueForType', () => {
it('should return true for null defaultValue', () => {
expect(validateDefaultValueForType(FieldMetadataType.TEXT, null)).toBe(
true,
);
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, null).isValid,
).toBe(true);
});
// Dynamic default values
it('should validate uuid dynamic default value for UUID type', () => {
expect(validateDefaultValueForType(FieldMetadataType.UUID, 'uuid')).toBe(
true,
);
expect(
validateDefaultValueForType(FieldMetadataType.UUID, 'uuid').isValid,
).toBe(true);
});
it('should validate now dynamic default value for DATE_TIME type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.DATE_TIME, 'now'),
validateDefaultValueForType(FieldMetadataType.DATE_TIME, 'now').isValid,
).toBe(true);
});
it('should return false for mismatched dynamic default value', () => {
expect(validateDefaultValueForType(FieldMetadataType.UUID, 'now')).toBe(
false,
);
expect(
validateDefaultValueForType(FieldMetadataType.UUID, 'now').isValid,
).toBe(false);
});
// Static default values
it('should validate string default value for TEXT type', () => {
expect(validateDefaultValueForType(FieldMetadataType.TEXT, "'test'")).toBe(
true,
);
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, "'test'").isValid,
).toBe(true);
});
it('should return false for invalid string default value for TEXT type', () => {
expect(validateDefaultValueForType(FieldMetadataType.TEXT, 123)).toBe(
false,
);
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, 123).isValid,
).toBe(false);
});
it('should validate string default value for PHONE type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.PHONE, "'+123456789'"),
validateDefaultValueForType(FieldMetadataType.PHONE, "'+123456789'")
.isValid,
).toBe(true);
});
it('should return false for invalid string default value for PHONE type', () => {
expect(validateDefaultValueForType(FieldMetadataType.PHONE, 123)).toBe(
false,
);
expect(
validateDefaultValueForType(FieldMetadataType.PHONE, 123).isValid,
).toBe(false);
});
it('should validate string default value for EMAIL type', () => {
expect(
validateDefaultValueForType(
FieldMetadataType.EMAIL,
"'test@example.com'",
),
validateDefaultValueForType(FieldMetadataType.EMAIL, "'test@example.com'")
.isValid,
).toBe(true);
});
it('should return false for invalid string default value for EMAIL type', () => {
expect(validateDefaultValueForType(FieldMetadataType.EMAIL, 123)).toBe(
false,
);
expect(
validateDefaultValueForType(FieldMetadataType.EMAIL, 123).isValid,
).toBe(false);
});
it('should validate number default value for NUMBER type', () => {
expect(validateDefaultValueForType(FieldMetadataType.NUMBER, 100)).toBe(
true,
);
expect(
validateDefaultValueForType(FieldMetadataType.NUMBER, 100).isValid,
).toBe(true);
});
it('should return false for invalid number default value for NUMBER type', () => {
expect(validateDefaultValueForType(FieldMetadataType.NUMBER, '100')).toBe(
false,
);
expect(
validateDefaultValueForType(FieldMetadataType.NUMBER, '100').isValid,
).toBe(false);
});
it('should validate number default value for PROBABILITY type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.PROBABILITY, 0.5),
validateDefaultValueForType(FieldMetadataType.PROBABILITY, 0.5).isValid,
).toBe(true);
});
it('should return false for invalid number default value for PROBABILITY type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.PROBABILITY, '50%'),
validateDefaultValueForType(FieldMetadataType.PROBABILITY, '50%').isValid,
).toBe(false);
});
it('should validate boolean default value for BOOLEAN type', () => {
expect(validateDefaultValueForType(FieldMetadataType.BOOLEAN, true)).toBe(
true,
);
expect(
validateDefaultValueForType(FieldMetadataType.BOOLEAN, true).isValid,
).toBe(true);
});
it('should return false for invalid boolean default value for BOOLEAN type', () => {
expect(validateDefaultValueForType(FieldMetadataType.BOOLEAN, 'true')).toBe(
false,
);
expect(
validateDefaultValueForType(FieldMetadataType.BOOLEAN, 'true').isValid,
).toBe(false);
});
// LINK type
@ -109,7 +108,7 @@ describe('validateDefaultValueForType', () => {
validateDefaultValueForType(FieldMetadataType.LINK, {
label: "'http://example.com'",
url: "'Example'",
}),
}).isValid,
).toBe(true);
});
@ -120,7 +119,7 @@ describe('validateDefaultValueForType', () => {
// @ts-expect-error Just for testing purposes
{ label: 123, url: {} },
FieldMetadataType.LINK,
),
).isValid,
).toBe(false);
});
@ -130,7 +129,7 @@ describe('validateDefaultValueForType', () => {
validateDefaultValueForType(FieldMetadataType.CURRENCY, {
amountMicros: '100',
currencyCode: "'USD'",
}),
}).isValid,
).toBe(true);
});
@ -141,14 +140,15 @@ describe('validateDefaultValueForType', () => {
// @ts-expect-error Just for testing purposes
{ amountMicros: 100, currencyCode: "'USD'" },
FieldMetadataType.CURRENCY,
),
).isValid,
).toBe(false);
});
// Unknown type
it('should return false for unknown type', () => {
expect(
validateDefaultValueForType('unknown' as FieldMetadataType, "'test'"),
validateDefaultValueForType('unknown' as FieldMetadataType, "'test'")
.isValid,
).toBe(false);
});
});

View File

@ -1,5 +1,5 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ValidationError, validateSync } from 'class-validator';
import {
FieldMetadataClassValidation,
@ -49,17 +49,32 @@ export const defaultValueValidatorsMap = {
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
};
type ValidationResult = {
isValid: boolean;
errors: ValidationError[];
};
export const validateDefaultValueForType = (
type: FieldMetadataType,
defaultValue: FieldMetadataDefaultValue,
): boolean => {
if (defaultValue === null) return true;
): ValidationResult => {
if (defaultValue === null) {
return {
isValid: true,
errors: [],
};
}
const validators = defaultValueValidatorsMap[type];
const validators = defaultValueValidatorsMap[type] as any[];
if (!validators) return false;
if (!validators) {
return {
isValid: false,
errors: [],
};
}
const isValid = validators.some((validator) => {
const validationResults = validators.map((validator) => {
const conputedDefaultValue = isCompositeFieldMetadataType(type)
? defaultValue
: { value: defaultValue };
@ -69,14 +84,24 @@ export const validateDefaultValueForType = (
FieldMetadataClassValidation
>(validator, conputedDefaultValue as FieldMetadataClassValidation);
return (
validateSync(defaultValueInstance, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
}).length === 0
);
const errors = validateSync(defaultValueInstance, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
});
const isValid = errors.length === 0;
return {
isValid,
errors,
};
});
return isValid;
const isValid = validationResults.some((result) => result.isValid);
return {
isValid,
errors: validationResults.flatMap((result) => result.errors),
};
};

View File

@ -14,13 +14,17 @@ import {
FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { validateDefaultValueForType } from 'src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util';
import { LoggerService } from 'src/engine/integrations/logger/logger.service';
@Injectable()
@ValidatorConstraint({ name: 'isFieldMetadataDefaultValue', async: true })
export class IsFieldMetadataDefaultValue
implements ValidatorConstraintInterface
{
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
constructor(
private readonly fieldMetadataService: FieldMetadataService,
private readonly loggerService: LoggerService,
) {}
async validate(
value: FieldMetadataDefaultValue,
@ -48,7 +52,19 @@ export class IsFieldMetadataDefaultValue
type = fieldMetadata.type;
}
return validateDefaultValueForType(type, value);
const validationResult = validateDefaultValueForType(type, value);
if (!validationResult.isValid) {
this.loggerService.error(
{
message: 'Error during field validation',
errors: validationResult.errors,
},
'Field Metadata Validation',
);
}
return validationResult.isValid;
}
defaultMessage(): string {