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:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user