Refactor default value for select (#5343)

In this PR, we are refactoring two things:
- leverage field.defaultValue for Select and MultiSelect settings form
(instead of option.isDefault)
- use quoted string (ex: "'USD'") for string default values to embrace
backend format

---------

Co-authored-by: Thaïs Guigon <guigon.thais@gmail.com>
This commit is contained in:
Charles Bochet
2024-05-10 10:26:46 +02:00
committed by GitHub
parent 7728c09dba
commit 8590bd7227
40 changed files with 843 additions and 559 deletions

View File

@ -0,0 +1,73 @@
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
import { currencyCodeSchema } from '@/object-record/record-field/validation-schemas/currencyCodeSchema';
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { Select } from '@/ui/input/components/Select';
import { CardContent } from '@/ui/layout/card/components/CardContent';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
export const settingsDataModelFieldCurrencyFormSchema = z.object({
defaultValue: z.object({
currencyCode: simpleQuotesStringSchema.refine(
(value) =>
currencyCodeSchema.safeParse(stripSimpleQuotesFromString(value))
.success,
{ message: 'String is not a valid currencyCode' },
),
}),
});
type SettingsDataModelFieldCurrencyFormValues = z.infer<
typeof settingsDataModelFieldCurrencyFormSchema
>;
type SettingsDataModelFieldCurrencyFormProps = {
disabled?: boolean;
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue'>;
};
const OPTIONS = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
([value, { label, Icon }]) => ({
label,
value: applySimpleQuotesToString(value),
Icon,
}),
);
export const SettingsDataModelFieldCurrencyForm = ({
disabled,
fieldMetadataItem,
}: SettingsDataModelFieldCurrencyFormProps) => {
const { control } =
useFormContext<SettingsDataModelFieldCurrencyFormValues>();
const initialValue =
(fieldMetadataItem?.defaultValue?.currencyCode as CurrencyCode) ??
CurrencyCode.USD;
return (
<CardContent>
<Controller
name="defaultValue.currencyCode"
control={control}
defaultValue={applySimpleQuotesToString(initialValue)}
render={({ field: { onChange, value } }) => (
<Select
fullWidth
disabled={disabled}
label="Default Unit"
dropdownId="currency-unit-select"
value={value}
options={OPTIONS}
onChange={onChange}
/>
)}
/>
</CardContent>
);
};

View File

@ -0,0 +1,70 @@
import { useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
settingsDataModelFieldMultiSelectFormSchema,
SettingsDataModelFieldSelectForm,
settingsDataModelFieldSelectFormSchema,
} from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/hooks/useSelectSettingsFormInitialValues';
import {
SettingsDataModelFieldPreviewCard,
SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
const selectOrMultiSelectFormSchema = z.union([
settingsDataModelFieldSelectFormSchema,
settingsDataModelFieldMultiSelectFormSchema,
]);
type SettingsDataModelFieldSettingsFormValues = z.infer<
typeof selectOrMultiSelectFormSchema
>;
type SettingsDataModelFieldSelectSettingsFormCardProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'options'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
export const SettingsDataModelFieldSelectSettingsFormCard = ({
fieldMetadataItem,
objectMetadataItem,
}: SettingsDataModelFieldSelectSettingsFormCardProps) => {
const { initialOptions, initialDefaultValue } =
useSelectSettingsFormInitialValues({
fieldMetadataItem,
});
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldSettingsFormValues>();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
fieldMetadataItem={{
...fieldMetadataItem,
defaultValue: watchFormValue('defaultValue', initialDefaultValue),
options: watchFormValue('options', initialOptions),
}}
objectMetadataItem={objectMetadataItem}
/>
}
form={
<SettingsDataModelFieldSelectForm
fieldMetadataItem={fieldMetadataItem}
/>
}
/>
);
};

View File

@ -1,4 +1,3 @@
import { useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import omit from 'lodash.omit';
import { z } from 'zod';
@ -9,17 +8,18 @@ import {
settingsDataModelFieldBooleanFormSchema,
} from '@/settings/data-model/components/SettingsDataModelDefaultValue';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
SettingsDataModelFieldCurrencyForm,
settingsDataModelFieldCurrencyFormSchema,
} from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm';
import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/components/SettingsObjectFieldRelationForm';
import {
SettingsDataModelFieldSelectForm,
settingsDataModelFieldMultiSelectFormSchema,
settingsDataModelFieldSelectFormSchema,
} from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
import {
SettingsDataModelFieldCurrencyForm,
settingsDataModelFieldCurrencyFormSchema,
} from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldCurrencyForm';
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldRelationSettingsFormCard';
import { SettingsDataModelFieldSelectSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSelectSettingsFormCard';
import {
SettingsDataModelFieldPreviewCard,
SettingsDataModelFieldPreviewCardProps,
@ -39,11 +39,13 @@ const relationFieldFormSchema = z
.merge(settingsDataModelFieldRelationFormSchema);
const selectFieldFormSchema = z
.object({
type: z.enum([FieldMetadataType.Select, FieldMetadataType.MultiSelect]),
})
.object({ type: z.literal(FieldMetadataType.Select) })
.merge(settingsDataModelFieldSelectFormSchema);
const multiSelectFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.MultiSelect) })
.merge(settingsDataModelFieldMultiSelectFormSchema);
const otherFieldsFormSchema = z.object({
type: z.enum(
Object.keys(
@ -65,14 +67,11 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
currencyFieldFormSchema,
relationFieldFormSchema,
selectFieldFormSchema,
multiSelectFieldFormSchema,
otherFieldsFormSchema,
],
);
type SettingsDataModelFieldSettingsFormValues = z.infer<
typeof settingsDataModelFieldSettingsFormSchema
>;
type SettingsDataModelFieldSettingsFormCardProps = {
disableCurrencyForm?: boolean;
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> &
@ -84,11 +83,6 @@ const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
const StyledPreviewContent = styled.div`
display: flex;
gap: 6px;
`;
const previewableTypes = [
FieldMetadataType.Boolean,
FieldMetadataType.Currency,
@ -112,9 +106,6 @@ export const SettingsDataModelFieldSettingsFormCard = ({
fieldMetadataItem,
objectMetadataItem,
}: SettingsDataModelFieldSettingsFormCardProps) => {
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldSettingsFormValues>();
if (!previewableTypes.includes(fieldMetadataItem.type)) return null;
if (fieldMetadataItem.type === FieldMetadataType.Relation) {
@ -126,16 +117,25 @@ export const SettingsDataModelFieldSettingsFormCard = ({
);
}
if (
fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
) {
return (
<SettingsDataModelFieldSelectSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
);
}
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledPreviewContent>
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
selectOptions={watchFormValue('options')}
/>
</StyledPreviewContent>
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
}
form={
fieldMetadataItem.type === FieldMetadataType.Boolean ? (
@ -147,14 +147,6 @@ export const SettingsDataModelFieldSettingsFormCard = ({
disabled={disableCurrencyForm}
fieldMetadataItem={fieldMetadataItem}
/>
) : fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect ? (
<SettingsDataModelFieldSelectForm
fieldMetadataItem={fieldMetadataItem}
isMultiSelect={
fieldMetadataItem.type === FieldMetadataType.MultiSelect
}
/>
) : undefined
}
/>

View File

@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { v4 } from 'uuid';
import {
FieldMetadataItem,
FieldMetadataItemOption,
} from '@/object-metadata/types/FieldMetadataItem';
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
const DEFAULT_OPTION: FieldMetadataItemOption = {
color: 'green',
id: v4(),
label: 'Option 1',
position: 0,
value: getOptionValueFromLabel('Option 1'),
};
export const useSelectSettingsFormInitialValues = ({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue' | 'options'>;
}) => {
const initialDefaultValue = fieldMetadataItem.defaultValue ?? null;
const initialOptions = useMemo(
() =>
fieldMetadataItem.options?.length
? [...fieldMetadataItem.options].sort(
(optionA, optionB) => optionA.position - optionB.position,
)
: [DEFAULT_OPTION],
[fieldMetadataItem.options],
);
return {
initialDefaultValue,
initialOptions,
};
};

View File

@ -0,0 +1,34 @@
import { generateNewSelectOptionLabel } from '@/settings/data-model/fields/forms/utils/generateNewSelectOptionLabel';
describe('generateNewSelectOptionLabel', () => {
it('generates a new select option label', () => {
// Given
const options = [
{ label: 'Option 1' },
{ label: 'Option 2' },
{ label: 'Lorem ipsum' },
];
// When
const newLabel = generateNewSelectOptionLabel(options);
// Then
expect(newLabel).toBe('Option 4');
});
it('iterates until it finds an unique label', () => {
// Given
const options = [
{ label: 'Option 1' },
{ label: 'Option 2' },
{ label: 'Option 4' },
{ label: 'Option 5' },
];
// When
const newLabel = generateNewSelectOptionLabel(options);
// Then
expect(newLabel).toBe('Option 6');
});
});

View File

@ -0,0 +1,39 @@
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
describe('getOptionValueFromLabel', () => {
it('should return the option value from the label', () => {
const label = 'Example Label';
const expected = 'EXAMPLE_LABEL';
const result = getOptionValueFromLabel(label);
expect(result).toEqual(expected);
});
it('should handle labels with accents', () => {
const label = 'Éxàmplè Làbèl';
const expected = 'EXAMPLE_LABEL';
const result = getOptionValueFromLabel(label);
expect(result).toEqual(expected);
});
it('should handle labels with special characters', () => {
const label = 'Example!@#$%^&*() Label';
const expected = 'EXAMPLE_LABEL';
const result = getOptionValueFromLabel(label);
expect(result).toEqual(expected);
});
it('should handle labels with emojis', () => {
const label = '📱 Example Label';
const expected = 'EXAMPLE_LABEL';
const result = getOptionValueFromLabel(label);
expect(result).toEqual(expected);
});
});

View File

@ -0,0 +1,20 @@
import { getNextThemeColor } from 'twenty-ui';
import { v4 } from 'uuid';
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { generateNewSelectOptionLabel } from '@/settings/data-model/fields/forms/utils/generateNewSelectOptionLabel';
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
export const generateNewSelectOption = (
options: FieldMetadataItemOption[],
): FieldMetadataItemOption => {
const newOptionLabel = generateNewSelectOptionLabel(options);
return {
color: getNextThemeColor(options[options.length - 1].color),
id: v4(),
label: newOptionLabel,
position: options.length,
value: getOptionValueFromLabel(newOptionLabel),
};
};

View File

@ -0,0 +1,13 @@
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
export const generateNewSelectOptionLabel = (
values: Pick<FieldMetadataItemOption, 'label'>[],
iteration = 1,
): string => {
const newOptionLabel = `Option ${values.length + iteration}`;
const labelExists = values.some((value) => value.label === newOptionLabel);
return labelExists
? generateNewSelectOptionLabel(values, iteration + 1)
: newOptionLabel;
};

View File

@ -0,0 +1,15 @@
import snakeCase from 'lodash.snakecase';
export const getOptionValueFromLabel = (label: string) => {
// Remove accents
const unaccentedLabel = label
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
// Remove special characters
const noSpecialCharactersLabel = unaccentedLabel.replace(
/[^a-zA-Z0-9 ]/g,
'',
);
return snakeCase(noSpecialCharactersLabel).toUpperCase();
};