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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 843 additions and 559 deletions

View File

@ -1,8 +0,0 @@
import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
export type FieldMetadataOption = {
color?: ThemeColor;
id?: string;
isDefault?: boolean;
label: string;
};

View File

@ -1,59 +1,33 @@
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { import { formatFieldMetadataItemInput } from '../formatFieldMetadataItemInput';
formatFieldMetadataItemInput,
getOptionValueFromLabel,
} from '../formatFieldMetadataItemInput';
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);
});
});
describe('formatFieldMetadataItemInput', () => { describe('formatFieldMetadataItemInput', () => {
it('should format the field metadata item input correctly', () => { it('should format the field metadata item input correctly', () => {
const options: FieldMetadataItemOption[] = [
{
id: '1',
label: 'Option 1',
color: 'red' as const,
position: 0,
value: 'OPTION_1',
},
{
id: '2',
label: 'Option 2',
color: 'blue' as const,
position: 1,
value: 'OPTION_2',
},
];
const input = { const input = {
defaultValue: "'OPTION_1'",
label: 'Example Label', label: 'Example Label',
icon: 'example-icon', icon: 'example-icon',
type: FieldMetadataType.Select, type: FieldMetadataType.Select,
description: 'Example description', description: 'Example description',
options: [ options,
{ id: '1', label: 'Option 1', color: 'red' as const, isDefault: true },
{ id: '2', label: 'Option 2', color: 'blue' as const },
],
}; };
const expected = { const expected = {
@ -61,22 +35,7 @@ describe('formatFieldMetadataItemInput', () => {
icon: 'example-icon', icon: 'example-icon',
label: 'Example Label', label: 'Example Label',
name: 'exampleLabel', name: 'exampleLabel',
options: [ options,
{
id: '1',
label: 'Option 1',
color: 'red',
position: 0,
value: 'OPTION_1',
},
{
id: '2',
label: 'Option 2',
color: 'blue',
position: 1,
value: 'OPTION_2',
},
],
defaultValue: "'OPTION_1'", defaultValue: "'OPTION_1'",
}; };
@ -108,15 +67,29 @@ describe('formatFieldMetadataItemInput', () => {
}); });
it('should format the field metadata item multi select input correctly', () => { it('should format the field metadata item multi select input correctly', () => {
const options: FieldMetadataItemOption[] = [
{
id: '1',
label: 'Option 1',
color: 'red' as const,
position: 0,
value: 'OPTION_1',
},
{
id: '2',
label: 'Option 2',
color: 'blue' as const,
position: 1,
value: 'OPTION_2',
},
];
const input = { const input = {
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
label: 'Example Label', label: 'Example Label',
icon: 'example-icon', icon: 'example-icon',
type: FieldMetadataType.MultiSelect, type: FieldMetadataType.MultiSelect,
description: 'Example description', description: 'Example description',
options: [ options,
{ id: '1', label: 'Option 1', color: 'red' as const, isDefault: true },
{ id: '2', label: 'Option 2', color: 'blue' as const, isDefault: true },
],
}; };
const expected = { const expected = {
@ -124,22 +97,7 @@ describe('formatFieldMetadataItemInput', () => {
icon: 'example-icon', icon: 'example-icon',
label: 'Example Label', label: 'Example Label',
name: 'exampleLabel', name: 'exampleLabel',
options: [ options,
{
id: '1',
label: 'Option 1',
color: 'red',
position: 0,
value: 'OPTION_1',
},
{
id: '2',
label: 'Option 2',
color: 'blue',
position: 1,
value: 'OPTION_2',
},
],
defaultValue: ["'OPTION_1'", "'OPTION_2'"], defaultValue: ["'OPTION_1'", "'OPTION_2'"],
}; };

View File

@ -1,82 +1,22 @@
import toSnakeCase from 'lodash.snakecase';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getDefaultValueForBackend } from '@/object-metadata/utils/getDefaultValueForBackend';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { formatMetadataLabelToMetadataNameOrThrows } from '~/pages/settings/data-model/utils/format-metadata-label-to-name.util'; import { formatMetadataLabelToMetadataNameOrThrows } from '~/pages/settings/data-model/utils/format-metadata-label-to-name.util';
import { isDefined } from '~/utils/isDefined';
import { FieldMetadataOption } from '../types/FieldMetadataOption';
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 toSnakeCase(noSpecialCharactersLabel).toUpperCase();
};
export const formatFieldMetadataItemInput = ( export const formatFieldMetadataItemInput = (
input: Partial< input: Partial<
Pick< Pick<
FieldMetadataItem, FieldMetadataItem,
'type' | 'label' | 'defaultValue' | 'icon' | 'description' 'type' | 'label' | 'defaultValue' | 'icon' | 'description' | 'options'
> >
> & { options?: FieldMetadataOption[] }, >,
) => { ) => {
const options = input.options as FieldMetadataOption[] | undefined;
let defaultValue = input.defaultValue;
if (input.type === FieldMetadataType.MultiSelect) {
defaultValue = options
?.filter((option) => option.isDefault)
?.map((defaultOption) => getOptionValueFromLabel(defaultOption.label));
}
if (input.type === FieldMetadataType.Select) {
const defaultOption = options?.find((option) => option.isDefault);
defaultValue = isDefined(defaultOption)
? getOptionValueFromLabel(defaultOption.label)
: undefined;
}
// Check if options has unique values
if (options !== undefined) {
// Compute the values based on the label
const values = options.map((option) =>
getOptionValueFromLabel(option.label),
);
if (new Set(values).size !== options.length) {
throw new Error(
`Options must have unique values, but contains the following duplicates ${values.join(
', ',
)}`,
);
}
}
const label = input.label?.trim(); const label = input.label?.trim();
return { return {
defaultValue: defaultValue: input.defaultValue,
isDefined(defaultValue) && input.type
? getDefaultValueForBackend(defaultValue, input.type)
: undefined,
description: input.description?.trim() ?? null, description: input.description?.trim() ?? null,
icon: input.icon, icon: input.icon,
label, label,
name: label ? formatMetadataLabelToMetadataNameOrThrows(label) : undefined, name: label ? formatMetadataLabelToMetadataNameOrThrows(label) : undefined,
options: options?.map((option, index) => ({ options: input.options,
color: option.color,
id: option.id,
label: option.label.trim(),
position: index,
value: getOptionValueFromLabel(option.label),
})),
}; };
}; };

View File

@ -1,22 +0,0 @@
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
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 CurrencyCode,
} satisfies FieldCurrencyValue;
} else if (fieldMetadataType === FieldMetadataType.Select) {
return defaultValue ? `'${defaultValue}'` : null;
} else if (fieldMetadataType === FieldMetadataType.MultiSelect) {
return defaultValue.map((value: string) => `'${value}'`);
}
return defaultValue;
};

View File

@ -0,0 +1,46 @@
import { z } from 'zod';
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
const selectOptionSchema = z
.object({
color: themeColorSchema,
id: z.string(),
label: z.string().trim().min(1),
position: z.number(),
value: z.string(),
})
.refine((option) => option.value === getOptionValueFromLabel(option.label), {
message: 'Value does not match label',
}) satisfies z.ZodType<FieldMetadataItemOption>;
export const selectOptionsSchema = z
.array(selectOptionSchema)
.min(1)
.refine(
(options) => {
const optionIds = options.map(({ id }) => id);
return new Set(optionIds).size === options.length;
},
{
message: 'Options must have unique ids',
},
)
.refine(
(options) => {
const optionValues = options.map(({ value }) => value);
return new Set(optionValues).size === options.length;
},
{
message: 'Options must have unique values',
},
)
.refine(
(options) =>
[...options].sort().every((option, index) => option.position === index),
{
message: 'Options positions must be sequential',
},
);

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
export const currencyCodeSchema = z.nativeEnum(CurrencyCode);

View File

@ -2,47 +2,54 @@ import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { DropResult } from '@hello-pangea/dnd'; import { DropResult } from '@hello-pangea/dnd';
import { IconPlus } from 'twenty-ui'; import { IconPlus } from 'twenty-ui';
import { v4 } from 'uuid';
import { z } from 'zod'; import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import {
import { SettingsObjectFieldSelectFormOption } from '@/settings/data-model/types/SettingsObjectFieldSelectFormOption'; FieldMetadataItem,
FieldMetadataItemOption,
} from '@/object-metadata/types/FieldMetadataItem';
import { selectOptionsSchema } from '@/object-metadata/validation-schemas/selectOptionsSchema';
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/hooks/useSelectSettingsFormInitialValues';
import { generateNewSelectOption } from '@/settings/data-model/fields/forms/utils/generateNewSelectOption';
import { isSelectOptionDefaultValue } from '@/settings/data-model/utils/isSelectOptionDefaultValue';
import { LightButton } from '@/ui/input/button/components/LightButton'; import { LightButton } from '@/ui/input/button/components/LightButton';
import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardContent } from '@/ui/layout/card/components/CardContent';
import { CardFooter } from '@/ui/layout/card/components/CardFooter'; import { CardFooter } from '@/ui/layout/card/components/CardFooter';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { import { FieldMetadataType } from '~/generated-metadata/graphql';
MAIN_COLOR_NAMES,
ThemeColor,
} from '@/ui/theme/constants/MainColorNames';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
import { moveArrayItem } from '~/utils/array/moveArrayItem'; import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow'; import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow';
// TODO: rename to SettingsDataModelFieldSelectForm and move to settings/data-model/fields/forms/components // TODO: rename to SettingsDataModelFieldSelectForm and move to settings/data-model/fields/forms/components
export const settingsDataModelFieldSelectFormSchema = z.object({ export const settingsDataModelFieldSelectFormSchema = z.object({
options: z defaultValue: simpleQuotesStringSchema.nullable(),
.array( options: selectOptionsSchema,
z.object({
color: themeColorSchema,
value: z.string(),
isDefault: z.boolean().optional(),
label: z.string().min(1),
}),
)
.min(1),
}); });
export const settingsDataModelFieldMultiSelectFormSchema = z.object({
defaultValue: z.array(simpleQuotesStringSchema).nullable(),
options: selectOptionsSchema,
});
const selectOrMultiSelectFormSchema = z.union([
settingsDataModelFieldSelectFormSchema,
settingsDataModelFieldMultiSelectFormSchema,
]);
export type SettingsDataModelFieldSelectFormValues = z.infer< export type SettingsDataModelFieldSelectFormValues = z.infer<
typeof settingsDataModelFieldSelectFormSchema typeof selectOrMultiSelectFormSchema
>; >;
type SettingsDataModelFieldSelectFormProps = { type SettingsDataModelFieldSelectFormProps = {
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'options'>; fieldMetadataItem: Pick<
isMultiSelect?: boolean; FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
}; };
const StyledContainer = styled(CardContent)` const StyledContainer = styled(CardContent)`
@ -68,155 +75,178 @@ const StyledButton = styled(LightButton)`
width: 100%; width: 100%;
`; `;
const getNextColor = (currentColor: ThemeColor) => {
const currentColorIndex = MAIN_COLOR_NAMES.findIndex(
(color) => color === currentColor,
);
const nextColorIndex = (currentColorIndex + 1) % MAIN_COLOR_NAMES.length;
return MAIN_COLOR_NAMES[nextColorIndex];
};
const getDefaultValueOptionIndexes = (
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'options'>,
) =>
fieldMetadataItem?.options?.reduce<number[]>((result, option, index) => {
if (
Array.isArray(fieldMetadataItem?.defaultValue) &&
fieldMetadataItem?.defaultValue.includes(`'${option.value}'`)
) {
return [...result, index];
}
// Ensure default value is unique for simple Select field
if (
!result.length &&
fieldMetadataItem?.defaultValue === `'${option.value}'`
) {
return [index];
}
return result;
}, []);
const DEFAULT_OPTION: SettingsObjectFieldSelectFormOption = {
color: 'green',
label: 'Option 1',
value: v4(),
};
export const SettingsDataModelFieldSelectForm = ({ export const SettingsDataModelFieldSelectForm = ({
fieldMetadataItem, fieldMetadataItem,
isMultiSelect = false,
}: SettingsDataModelFieldSelectFormProps) => { }: SettingsDataModelFieldSelectFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldSelectFormValues>(); const { initialDefaultValue, initialOptions } =
useSelectSettingsFormInitialValues({ fieldMetadataItem });
const initialDefaultValueOptionIndexes = const {
getDefaultValueOptionIndexes(fieldMetadataItem); control,
setValue: setFormValue,
const initialValue = fieldMetadataItem?.options watch: watchFormValue,
?.map((option, index) => ({ getValues,
...option, } = useFormContext<SettingsDataModelFieldSelectFormValues>();
isDefault: initialDefaultValueOptionIndexes?.includes(index),
}))
.sort((optionA, optionB) => optionA.position - optionB.position);
const handleDragEnd = ( const handleDragEnd = (
values: SettingsObjectFieldSelectFormOption[], values: FieldMetadataItemOption[],
result: DropResult, result: DropResult,
onChange: (options: SettingsObjectFieldSelectFormOption[]) => void, onChange: (options: FieldMetadataItemOption[]) => void,
) => { ) => {
if (!result.destination) return; if (!result.destination) return;
const nextOptions = moveArrayItem(values, { const nextOptions = moveArrayItem(values, {
fromIndex: result.source.index, fromIndex: result.source.index,
toIndex: result.destination.index, toIndex: result.destination.index,
}); }).map((option, index) => ({ ...option, position: index }));
onChange(nextOptions); onChange(nextOptions);
}; };
const findNewLabel = (values: SettingsObjectFieldSelectFormOption[]) => { const isOptionDefaultValue = (
let optionIndex = values.length + 1; optionValue: FieldMetadataItemOption['value'],
while (optionIndex < 100) { ) =>
const newLabel = `Option ${optionIndex}`; isSelectOptionDefaultValue(optionValue, {
if (!values.map((value) => value.label).includes(newLabel)) { type: fieldMetadataItem.type,
return newLabel; defaultValue: watchFormValue('defaultValue'),
} });
optionIndex += 1;
const handleSetOptionAsDefault = (
optionValue: FieldMetadataItemOption['value'],
) => {
if (isOptionDefaultValue(optionValue)) return;
if (fieldMetadataItem.type === FieldMetadataType.Select) {
setFormValue('defaultValue', applySimpleQuotesToString(optionValue), {
shouldDirty: true,
});
return;
}
const previousDefaultValue = getValues('defaultValue');
if (
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
(Array.isArray(previousDefaultValue) || previousDefaultValue === null)
) {
setFormValue(
'defaultValue',
[
...(previousDefaultValue ?? []),
applySimpleQuotesToString(optionValue),
],
{ shouldDirty: true },
);
}
};
const handleRemoveOptionAsDefault = (
optionValue: FieldMetadataItemOption['value'],
) => {
if (!isOptionDefaultValue(optionValue)) return;
if (fieldMetadataItem.type === FieldMetadataType.Select) {
setFormValue('defaultValue', null, { shouldDirty: true });
return;
}
const previousDefaultValue = getValues('defaultValue');
if (
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
(Array.isArray(previousDefaultValue) || previousDefaultValue === null)
) {
const nextDefaultValue = previousDefaultValue?.filter(
(value) => value !== applySimpleQuotesToString(optionValue),
);
setFormValue(
'defaultValue',
nextDefaultValue?.length ? nextDefaultValue : null,
{ shouldDirty: true },
);
} }
return `Option 100`;
}; };
return ( return (
<Controller <>
name="options" <Controller
control={control} name="defaultValue"
defaultValue={initialValue?.length ? initialValue : [DEFAULT_OPTION]} control={control}
render={({ field: { onChange, value: options } }) => ( defaultValue={initialDefaultValue}
<> render={() => <></>}
<StyledContainer> />
<StyledLabel>Options</StyledLabel> <Controller
<DraggableList name="options"
onDragEnd={(result) => handleDragEnd(options, result, onChange)} control={control}
draggableItems={ defaultValue={initialOptions}
<> render={({ field: { onChange, value: options } }) => (
{options.map((option, index) => ( <>
<DraggableItem <StyledContainer>
key={option.value} <StyledLabel>Options</StyledLabel>
draggableId={option.value} <DraggableList
index={index} onDragEnd={(result) => handleDragEnd(options, result, onChange)}
isDragDisabled={options.length === 1} draggableItems={
itemComponent={ <>
<SettingsObjectFieldSelectFormOptionRow {options.map((option, index) => (
key={option.value} <DraggableItem
isDefault={option.isDefault} key={option.id}
onChange={(nextOption) => { draggableId={option.id}
const nextOptions = index={index}
isMultiSelect || !nextOption.isDefault isDragDisabled={options.length === 1}
? [...options] itemComponent={
: // Reset simple Select default option before setting the new one <SettingsObjectFieldSelectFormOptionRow
options.map<SettingsObjectFieldSelectFormOption>( key={option.id}
(value) => ({ ...value, isDefault: false }), option={option}
); onChange={(nextOption) => {
nextOptions.splice(index, 1, nextOption); const nextOptions = [...options];
onChange(nextOptions); nextOptions.splice(index, 1, nextOption);
}} onChange(nextOptions);
onRemove={
options.length > 1 // Update option value in defaultValue if value has changed
? () => { if (
const nextOptions = [...options]; nextOption.value !== option.value &&
nextOptions.splice(index, 1); isOptionDefaultValue(option.value)
onChange(nextOptions); ) {
} handleRemoveOptionAsDefault(option.value);
: undefined handleSetOptionAsDefault(nextOption.value);
} }
option={option} }}
/> onRemove={
} options.length > 1
/> ? () => {
))} const nextOptions = [...options];
</> nextOptions.splice(index, 1);
} onChange(nextOptions);
/> }
</StyledContainer> : undefined
<StyledFooter> }
<StyledButton isDefault={isOptionDefaultValue(option.value)}
title="Add option" onSetAsDefault={() =>
Icon={IconPlus} handleSetOptionAsDefault(option.value)
onClick={() => }
onChange([ onRemoveAsDefault={() =>
...options, handleRemoveOptionAsDefault(option.value)
{ }
color: getNextColor(options[options.length - 1].color), />
label: findNewLabel(options), }
value: v4(), />
}, ))}
]) </>
} }
/> />
</StyledFooter> </StyledContainer>
</> <StyledFooter>
)} <StyledButton
/> title="Add option"
Icon={IconPlus}
onClick={() =>
onChange([...options, generateNewSelectOption(options)])
}
/>
</StyledFooter>
</>
)}
/>
</>
); );
}; };

View File

@ -10,6 +10,8 @@ import {
} from 'twenty-ui'; } from 'twenty-ui';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { getOptionValueFromLabel } from '@/settings/data-model/fields/forms/utils/getOptionValueFromLabel';
import { ColorSample } from '@/ui/display/color/components/ColorSample'; import { ColorSample } from '@/ui/display/color/components/ColorSample';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
@ -21,14 +23,14 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor'; import { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor';
import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames'; import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames';
import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption';
type SettingsObjectFieldSelectFormOptionRowProps = { type SettingsObjectFieldSelectFormOptionRowProps = {
className?: string; className?: string;
isDefault?: boolean; isDefault?: boolean;
onChange: (value: SettingsObjectFieldSelectFormOption) => void; onChange: (value: FieldMetadataItemOption) => void;
onRemove?: () => void; onRemove?: () => void;
option: SettingsObjectFieldSelectFormOption; onSetAsDefault?: () => void;
onRemoveAsDefault?: () => void;
option: FieldMetadataItemOption;
}; };
const StyledRow = styled.div` const StyledRow = styled.div`
@ -58,6 +60,8 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
isDefault, isDefault,
onChange, onChange,
onRemove, onRemove,
onSetAsDefault,
onRemoveAsDefault,
option, option,
}: SettingsObjectFieldSelectFormOptionRowProps) => { }: SettingsObjectFieldSelectFormOptionRowProps) => {
const theme = useTheme(); const theme = useTheme();
@ -106,7 +110,9 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
/> />
<StyledOptionInput <StyledOptionInput
value={option.label} value={option.label}
onChange={(label) => onChange({ ...option, label })} onChange={(label) =>
onChange({ ...option, label, value: getOptionValueFromLabel(label) })
}
RightIcon={isDefault ? IconCheck : undefined} RightIcon={isDefault ? IconCheck : undefined}
/> />
<Dropdown <Dropdown
@ -124,7 +130,7 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
LeftIcon={IconX} LeftIcon={IconX}
text="Remove as default" text="Remove as default"
onClick={() => { onClick={() => {
onChange({ ...option, isDefault: false }); onRemoveAsDefault?.();
closeActionsDropdown(); closeActionsDropdown();
}} }}
/> />
@ -133,7 +139,7 @@ export const SettingsObjectFieldSelectFormOptionRow = ({
LeftIcon={IconCheck} LeftIcon={IconCheck}
text="Set as default" text="Set as default"
onClick={() => { onClick={() => {
onChange({ ...option, isDefault: true }); onSetAsDefault?.();
closeActionsDropdown(); closeActionsDropdown();
}} }}
/> />

View File

@ -3,15 +3,22 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; 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 { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { Select } from '@/ui/input/components/Select'; import { Select } from '@/ui/input/components/Select';
import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardContent } from '@/ui/layout/card/components/CardContent';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
// TODO: rename to SettingsDataModelFieldCurrencyForm and move to settings/data-model/fields/forms/components import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
export const settingsDataModelFieldCurrencyFormSchema = z.object({ export const settingsDataModelFieldCurrencyFormSchema = z.object({
defaultValue: z.object({ defaultValue: z.object({
currencyCode: z.nativeEnum(CurrencyCode), currencyCode: simpleQuotesStringSchema.refine(
(value) =>
currencyCodeSchema.safeParse(stripSimpleQuotesFromString(value))
.success,
{ message: 'String is not a valid currencyCode' },
),
}), }),
}); });
@ -27,7 +34,7 @@ type SettingsDataModelFieldCurrencyFormProps = {
const OPTIONS = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map( const OPTIONS = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
([value, { label, Icon }]) => ({ ([value, { label, Icon }]) => ({
label, label,
value: value as CurrencyCode, value: applySimpleQuotesToString(value),
Icon, Icon,
}), }),
); );
@ -48,7 +55,7 @@ export const SettingsDataModelFieldCurrencyForm = ({
<Controller <Controller
name="defaultValue.currencyCode" name="defaultValue.currencyCode"
control={control} control={control}
defaultValue={initialValue} defaultValue={applySimpleQuotesToString(initialValue)}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<Select <Select
fullWidth fullWidth

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

View File

@ -8,7 +8,6 @@ import { FieldDisplay } from '@/object-record/record-field/components/FieldDispl
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { BooleanFieldInput } from '@/object-record/record-field/meta-types/input/components/BooleanFieldInput'; import { BooleanFieldInput } from '@/object-record/record-field/meta-types/input/components/BooleanFieldInput';
import { RatingFieldInput } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput'; import { RatingFieldInput } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect'; import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect';
import { SettingsDataModelSetRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect'; import { SettingsDataModelSetRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect';
import { useFieldPreview } from '@/settings/data-model/fields/preview/hooks/useFieldPreview'; import { useFieldPreview } from '@/settings/data-model/fields/preview/hooks/useFieldPreview';
@ -17,14 +16,13 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsDataModelFieldPreviewProps = { export type SettingsDataModelFieldPreviewProps = {
fieldMetadataItem: Pick< fieldMetadataItem: Pick<
FieldMetadataItem, FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' 'icon' | 'label' | 'type' | 'defaultValue' | 'options'
> & { > & {
id?: string; id?: string;
name?: string; name?: string;
}; };
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem; relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
shrink?: boolean; shrink?: boolean;
withFieldLabel?: boolean; withFieldLabel?: boolean;
}; };
@ -55,7 +53,6 @@ export const SettingsDataModelFieldPreview = ({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItem, objectMetadataItem,
relationObjectMetadataItem, relationObjectMetadataItem,
selectOptions,
shrink, shrink,
withFieldLabel = true, withFieldLabel = true,
}: SettingsDataModelFieldPreviewProps) => { }: SettingsDataModelFieldPreviewProps) => {
@ -69,7 +66,6 @@ export const SettingsDataModelFieldPreview = ({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItem, objectMetadataItem,
relationObjectMetadataItem, relationObjectMetadataItem,
selectOptions,
}); });
return ( return (
@ -107,7 +103,7 @@ export const SettingsDataModelFieldPreview = ({
objectMetadataNameSingular: objectMetadataItem.nameSingular, objectMetadataNameSingular: objectMetadataItem.nameSingular,
relationObjectMetadataNameSingular: relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular, relationObjectMetadataItem?.nameSingular,
options: selectOptions, options: fieldMetadataItem.options,
}, },
defaultValue: fieldMetadataItem.defaultValue, defaultValue: fieldMetadataItem.defaultValue,
}, },

View File

@ -28,7 +28,6 @@ export const SettingsDataModelFieldPreviewCard = ({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItem, objectMetadataItem,
relationObjectMetadataItem, relationObjectMetadataItem,
selectOptions,
shrink, shrink,
withFieldLabel = true, withFieldLabel = true,
}: SettingsDataModelFieldPreviewCardProps) => ( }: SettingsDataModelFieldPreviewCardProps) => (
@ -39,7 +38,6 @@ export const SettingsDataModelFieldPreviewCard = ({
objectMetadataItem={objectMetadataItem} objectMetadataItem={objectMetadataItem}
fieldMetadataItem={fieldMetadataItem} fieldMetadataItem={fieldMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem} relationObjectMetadataItem={relationObjectMetadataItem}
selectOptions={selectOptions}
shrink={shrink} shrink={shrink}
withFieldLabel={withFieldLabel} withFieldLabel={withFieldLabel}
/> />

View File

@ -4,26 +4,28 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { getFieldDefaultPreviewValue } from '@/settings/data-model/utils/getFieldDefaultPreviewValue'; import { getFieldDefaultPreviewValue } from '@/settings/data-model/utils/getFieldDefaultPreviewValue';
import { getFieldPreviewValueFromRecord } from '@/settings/data-model/utils/getFieldPreviewValueFromRecord'; import { getFieldPreviewValueFromRecord } from '@/settings/data-model/utils/getFieldPreviewValueFromRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
type UseFieldPreviewParams = { type UseFieldPreviewParams = {
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'type'> & { fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'type' | 'options' | 'defaultValue'
> & {
// id and name are undefined in create mode (field does not exist yet)
// and are defined in edit mode.
id?: string; id?: string;
name?: string; name?: string;
}; };
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem; relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
}; };
export const useFieldPreview = ({ export const useFieldPreview = ({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItem, objectMetadataItem,
relationObjectMetadataItem, relationObjectMetadataItem,
selectOptions,
}: UseFieldPreviewParams) => { }: UseFieldPreviewParams) => {
const isLabelIdentifier = const isLabelIdentifier =
!!fieldMetadataItem.id && !!fieldMetadataItem.id &&
@ -40,6 +42,9 @@ export const useFieldPreview = ({
objectNameSingular: objectMetadataItem.nameSingular, objectNameSingular: objectMetadataItem.nameSingular,
limit: 1, limit: 1,
skip: !fieldMetadataItem.name, skip: !fieldMetadataItem.name,
orderBy: {
[fieldMetadataItem.name ?? '']: 'AscNullsLast',
},
}); });
const [firstRecord] = records; const [firstRecord] = records;
@ -51,17 +56,17 @@ export const useFieldPreview = ({
name: fieldMetadataItem.name, name: fieldMetadataItem.name,
type: fieldMetadataItem.type, type: fieldMetadataItem.type,
}, },
selectOptions,
}) })
: null; : null;
const selectOptionValues = selectOptions?.map((option) => option.value);
const isValueFromFirstRecord = const isValueFromFirstRecord =
firstRecord && firstRecord &&
!isFieldValueEmpty({ !isFieldValueEmpty({
fieldDefinition: { type: fieldMetadataItem.type }, fieldDefinition: { type: fieldMetadataItem.type },
fieldValue: fieldPreviewValueFromFirstRecord, fieldValue: fieldPreviewValueFromFirstRecord,
selectOptionValues, selectOptionValues: fieldMetadataItem.options?.map(
(option) => option.value,
),
}); });
const { records: relationRecords } = useFindManyRecords({ const { records: relationRecords } = useFindManyRecords({
@ -83,7 +88,6 @@ export const useFieldPreview = ({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItem, objectMetadataItem,
relationObjectMetadataItem, relationObjectMetadataItem,
selectOptions,
}); });
const fieldName = const fieldName =

View File

@ -1,8 +0,0 @@
import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
export type SettingsObjectFieldSelectFormOption = {
color: ThemeColor;
isDefault?: boolean;
label: string;
value: string;
};

View File

@ -1,5 +1,4 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { import {
mockedCompanyObjectMetadataItem, mockedCompanyObjectMetadataItem,
mockedOpportunityObjectMetadataItem, mockedOpportunityObjectMetadataItem,
@ -10,35 +9,21 @@ import { getFieldDefaultPreviewValue } from '../getFieldDefaultPreviewValue';
describe('getFieldDefaultPreviewValue', () => { describe('getFieldDefaultPreviewValue', () => {
describe('SELECT field', () => { describe('SELECT field', () => {
it('returns the default select option', () => { it('returns the default select option value', () => {
// Given // Given
const objectMetadataItem = mockedOpportunityObjectMetadataItem; const objectMetadataItem = mockedOpportunityObjectMetadataItem;
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find( const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage', ({ name }) => name === 'stage',
)!; )!;
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
{
color: 'purple',
label: '🏭 Industry',
value: 'INDUSTRY',
},
{
color: 'pink',
isDefault: true,
label: '💊 Health',
value: 'HEALTH',
},
];
// When // When
const result = getFieldDefaultPreviewValue({ const result = getFieldDefaultPreviewValue({
objectMetadataItem, objectMetadataItem,
fieldMetadataItem, fieldMetadataItem: { ...fieldMetadataItem, defaultValue: "'MEETING'" },
selectOptions,
}); });
// Then // Then
expect(result).toEqual(selectOptions[1].value); expect(result).toEqual('MEETING');
}); });
it('returns the first select option if no default option was found', () => { it('returns the first select option if no default option was found', () => {
@ -47,28 +32,15 @@ describe('getFieldDefaultPreviewValue', () => {
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find( const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage', ({ name }) => name === 'stage',
)!; )!;
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
{
color: 'purple' as const,
label: '🏭 Industry',
value: 'INDUSTRY',
},
{
color: 'pink' as const,
label: '💊 Health',
value: 'HEALTH',
},
];
// When // When
const result = getFieldDefaultPreviewValue({ const result = getFieldDefaultPreviewValue({
objectMetadataItem, objectMetadataItem,
fieldMetadataItem, fieldMetadataItem: { ...fieldMetadataItem, defaultValue: null },
selectOptions,
}); });
// Then // Then
expect(result).toEqual(selectOptions[0].value); expect(result).toEqual(fieldMetadataItem.options![0].value);
}); });
}); });

View File

@ -1,106 +1,12 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { import {
mockedCompanyObjectMetadataItem, mockedCompanyObjectMetadataItem,
mockedOpportunityObjectMetadataItem,
mockedPersonObjectMetadataItem, mockedPersonObjectMetadataItem,
} from '~/testing/mock-data/metadata'; } from '~/testing/mock-data/metadata';
import { getFieldPreviewValueFromRecord } from '../getFieldPreviewValueFromRecord'; import { getFieldPreviewValueFromRecord } from '../getFieldPreviewValueFromRecord';
describe('getFieldPreviewValueFromRecord', () => { describe('getFieldPreviewValueFromRecord', () => {
describe('SELECT field', () => {
it('returns the select option corresponding to the record field value', () => {
// Given
const record: ObjectRecord = {
id: '',
stage: 'MEETING',
__typename: 'Opportunity',
};
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage',
)!;
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
{
color: 'red',
label: 'New',
value: 'NEW',
},
{
color: 'purple',
label: 'Screening',
value: 'SCREENING',
},
{
color: 'sky',
label: 'Meeting',
value: 'MEETING',
isDefault: true,
},
{
color: 'turquoise',
label: 'Proposal',
value: 'PROPOSAL',
},
{
color: 'yellow',
label: 'Customer',
value: 'CUSTOMER',
},
];
// When
const result = getFieldPreviewValueFromRecord({
record,
fieldMetadataItem,
selectOptions,
});
// Then
expect(result).toEqual(selectOptions[2].value);
});
it('returns undefined if the select option was not found', () => {
// Given
const record: ObjectRecord = {
id: '',
industry: 'DOES_NOT_EXIST',
__typename: 'Opportunity',
};
const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find(
({ name }) => name === 'stage',
)!;
const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [
{
color: 'purple',
label: '🏭 Industry',
value: 'INDUSTRY',
},
{
color: 'pink',
isDefault: true,
label: '💊 Health',
value: 'HEALTH',
},
{
color: 'turquoise',
label: '🌿 Green tech',
value: 'GREEN_TECH',
},
];
// When
const result = getFieldPreviewValueFromRecord({
record,
fieldMetadataItem,
selectOptions,
});
// Then
expect(result).toBeUndefined();
});
});
describe('RELATION field', () => { describe('RELATION field', () => {
it('returns the first relation record from a list of edges ("to many" relation)', () => { it('returns the first relation record from a list of edges ("to many" relation)', () => {
// Given // Given

View File

@ -0,0 +1,69 @@
import { FieldMetadataType } from '~/generated/graphql';
import { isSelectOptionDefaultValue } from '../isSelectOptionDefaultValue';
describe('isSelectOptionDefaultValue', () => {
describe('SELECT field', () => {
it('returns true if the option value matches the default value', () => {
// Given
const optionValue = 'OPTION_1';
const fieldMetadataItem = {
defaultValue: `'${optionValue}'`,
type: FieldMetadataType.Select,
};
// When
const result = isSelectOptionDefaultValue(optionValue, fieldMetadataItem);
// Then
expect(result).toBe(true);
});
it('returns false if the option value does not match the default value', () => {
// Given
const optionValue = 'OPTION_1';
const fieldMetadataItem = {
defaultValue: "'OPTION_2'",
type: FieldMetadataType.Select,
};
// When
const result = isSelectOptionDefaultValue(optionValue, fieldMetadataItem);
// Then
expect(result).toBe(false);
});
});
describe('MULTI_SELECT field', () => {
it('returns true if the option value is included in the default value array', () => {
// Given
const optionValue = 'OPTION_1';
const fieldMetadataItem = {
defaultValue: ["'OPTION_1'", "'OPTION_2'"],
type: FieldMetadataType.MultiSelect,
};
// When
const result = isSelectOptionDefaultValue(optionValue, fieldMetadataItem);
// Then
expect(result).toBe(true);
});
it('returns false if the option value is not included in the default value array', () => {
// Given
const optionValue = 'OPTION_1';
const fieldMetadataItem = {
defaultValue: ["'OPTION_2'", "'OPTION_3'"],
type: FieldMetadataType.MultiSelect,
};
// When
const result = isSelectOptionDefaultValue(optionValue, fieldMetadataItem);
// Then
expect(result).toBe(false);
});
});
});

View File

@ -2,39 +2,41 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig'; import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const getFieldDefaultPreviewValue = ({ export const getFieldDefaultPreviewValue = ({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItem, objectMetadataItem,
relationObjectMetadataItem, relationObjectMetadataItem,
selectOptions,
}: { }: {
fieldMetadataItem: Pick<FieldMetadataItem, 'type'> & { fieldMetadataItem: Pick<
FieldMetadataItem,
'type' | 'defaultValue' | 'options'
> & {
id?: string; id?: string;
name?: string; name?: string;
}; };
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem; relationObjectMetadataItem?: ObjectMetadataItem;
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
}) => { }) => {
if ( if (fieldMetadataItem.type === FieldMetadataType.Select) {
fieldMetadataItem.type === FieldMetadataType.Select && const defaultValue = fieldMetadataItem.defaultValue
isDefined(selectOptions) ? stripSimpleQuotesFromString(fieldMetadataItem.defaultValue)
) { : null;
const defaultSelectOption = return defaultValue ?? fieldMetadataItem.options?.[0]?.value ?? null;
selectOptions.find(({ isDefault }) => isDefault) || selectOptions[0];
return defaultSelectOption.value;
} }
if ( if (fieldMetadataItem.type === FieldMetadataType.MultiSelect) {
fieldMetadataItem.type === FieldMetadataType.MultiSelect && const defaultValues = fieldMetadataItem.defaultValue?.map(
isDefined(selectOptions) (defaultValue: `'${string}'`) =>
) { stripSimpleQuotesFromString(defaultValue),
return selectOptions.map((selectOption) => selectOption.value); );
return defaultValues?.length
? defaultValues
: fieldMetadataItem.options?.map(({ value }) => value) ?? null;
} }
if ( if (

View File

@ -1,26 +1,16 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getFieldPreviewValueFromRecord = ({ export const getFieldPreviewValueFromRecord = ({
record, record,
fieldMetadataItem, fieldMetadataItem,
selectOptions,
}: { }: {
record: ObjectRecord; record: ObjectRecord;
fieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>; fieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>;
selectOptions?: SettingsDataModelFieldSelectFormValues['options'];
}) => { }) => {
const recordFieldValue = record[fieldMetadataItem.name]; const recordFieldValue = record[fieldMetadataItem.name];
// Select field
if (fieldMetadataItem.type === FieldMetadataType.Select) {
return selectOptions?.find(
(selectOption) => selectOption.value === recordFieldValue,
)?.value;
}
// Relation fields (to many) // Relation fields (to many)
if ( if (
fieldMetadataItem.type === FieldMetadataType.Relation && fieldMetadataItem.type === FieldMetadataType.Relation &&

View File

@ -0,0 +1,25 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
export const isSelectOptionDefaultValue = (
optionValue: string,
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue' | 'type'>,
): boolean => {
if (fieldMetadataItem.type === FieldMetadataType.Select) {
return (
applySimpleQuotesToString(optionValue) === fieldMetadataItem.defaultValue
);
}
if (
fieldMetadataItem.type === FieldMetadataType.MultiSelect &&
Array.isArray(fieldMetadataItem.defaultValue)
) {
return fieldMetadataItem.defaultValue.includes(
applySimpleQuotesToString(optionValue),
);
}
return false;
};

View File

@ -6,7 +6,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import omit from 'lodash.omit'; import omit from 'lodash.omit';
import pick from 'lodash.pick'; import pick from 'lodash.pick';
import { IconArchive, IconSettings } from 'twenty-ui'; import { IconArchive, IconSettings } from 'twenty-ui';
import { v4 } from 'uuid';
import { z } from 'zod'; import { z } from 'zod';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
@ -125,14 +124,9 @@ export const SettingsObjectFieldEdit = () => {
Object.keys(otherDirtyFields), Object.keys(otherDirtyFields),
); );
const options = formattedInput.options?.map((option) => ({
...option,
id: option.id ?? v4(),
}));
await updateOneFieldMetadataItem({ await updateOneFieldMetadataItem({
fieldMetadataIdToUpdate: activeMetadataField.id, fieldMetadataIdToUpdate: activeMetadataField.id,
updatePayload: { ...formattedInput, options }, updatePayload: formattedInput,
}); });
} }

View File

@ -0,0 +1,11 @@
import { applySimpleQuotesToString } from '../applySimpleQuotesToString';
describe('applySimpleQuotesToString', () => {
it('wraps the input string with single quotes', () => {
const input = 'Hello, World!';
const result = applySimpleQuotesToString(input);
expect(result).toBe(`'${input}'`);
});
});

View File

@ -0,0 +1,32 @@
import { stripSimpleQuotesFromString } from '../stripSimpleQuotesFromString';
describe('stripSimpleQuotesFromString', () => {
it('removes surrounding single quotes from a string', () => {
// Given
const input = "'Hello, World!'";
// When
const output = stripSimpleQuotesFromString(input);
// Then
expect(output).toBe('Hello, World!');
});
it.each([
// Given
['no simple quotes'],
["'only at start"],
["only at end'"],
["mid'dle"],
[''],
])(
'returns the input without changes if the string does not start and end with single quotes (%s)',
(input) => {
// When
const output = stripSimpleQuotesFromString(input);
// Then
expect(output).toBe(input);
},
);
});

View File

@ -0,0 +1,3 @@
export const applySimpleQuotesToString = <T extends string>(
value: T,
): `'${T}'` => `'${value}'`;

View File

@ -0,0 +1,8 @@
import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema';
export const stripSimpleQuotesFromString = <Input extends string>(
value: Input,
) =>
(simpleQuotesStringSchema.safeParse(value).success
? value.slice(1, -1)
: value) as Input extends `'${infer Output}'` ? Output : Input;

View File

@ -0,0 +1,38 @@
import { SafeParseError } from 'zod';
import { simpleQuotesStringSchema } from '../simpleQuotesStringSchema';
describe('simpleQuotesStringSchema', () => {
it('validates a string with simple quotes', () => {
// Given
const input = "'with simple quotes'";
// When
const result = simpleQuotesStringSchema.parse(input);
// Then
expect(result).toBe(input);
});
it.each([
// Given
['no simple quotes'],
["'only at start"],
["only at end'"],
["mid'dle"],
[''],
])('fails for strings not wrapped in simple quotes (%s)', (input) => {
// When
const result = simpleQuotesStringSchema.safeParse(input);
// Then
expect(result.success).toBe(false);
expect((result as SafeParseError<string>).error.errors).toEqual([
{
code: 'custom',
message: 'String should be wrapped in simple quotes',
path: [],
},
]);
});
});

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
export const simpleQuotesStringSchema: z.ZodType<
`'${string}'`,
z.ZodTypeDef,
string
> = z
.string()
.refine(
(value: string): value is `'${string}'` =>
value.startsWith("'") && value.endsWith("'"),
{
message: 'String should be wrapped in simple quotes',
},
);

View File

@ -0,0 +1 @@
export default 'test-file-stub';

View File

@ -1,4 +1,9 @@
export default { import { JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const tsConfig = require('./tsconfig.json');
const jestConfig: JestConfigWithTsJest = {
displayName: 'twenty-ui', displayName: 'twenty-ui',
preset: '../../jest.preset.js', preset: '../../jest.preset.js',
setupFilesAfterEnv: ['./setupTests.ts'], setupFilesAfterEnv: ['./setupTests.ts'],
@ -15,7 +20,14 @@ export default {
}, },
], ],
}, },
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|webp|svg|svg\\?react)$':
'<rootDir>/__mocks__/imageMock.js',
...pathsToModuleNameMapper(tsConfig.compilerOptions.paths),
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
extensionsToTreatAsEsm: ['.ts', '.tsx'], extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageDirectory: './coverage', coverageDirectory: './coverage',
}; };
export default jestConfig;

View File

@ -32,3 +32,4 @@ export * from './constants/ThemeDark';
export * from './constants/ThemeLight'; export * from './constants/ThemeLight';
export * from './provider/ThemeProvider'; export * from './provider/ThemeProvider';
export * from './types/ThemeType'; export * from './types/ThemeType';
export * from './utils/getNextThemeColor';

View File

@ -0,0 +1,23 @@
import {
MAIN_COLOR_NAMES,
ThemeColor,
} from '@ui/theme/constants/MainColorNames';
import { getNextThemeColor } from '../getNextThemeColor';
describe('getNextThemeColor', () => {
it('returns the next theme color', () => {
const currentColor: ThemeColor = MAIN_COLOR_NAMES[0];
const nextColor: ThemeColor = MAIN_COLOR_NAMES[1];
expect(getNextThemeColor(currentColor)).toBe(nextColor);
});
it('returns the first color when reaching the end', () => {
const currentColor: ThemeColor =
MAIN_COLOR_NAMES[MAIN_COLOR_NAMES.length - 1];
const nextColor: ThemeColor = MAIN_COLOR_NAMES[0];
expect(getNextThemeColor(currentColor)).toBe(nextColor);
});
});

View File

@ -0,0 +1,9 @@
import { MAIN_COLOR_NAMES, ThemeColor } from '@ui/theme';
export const getNextThemeColor = (currentColor: ThemeColor): ThemeColor => {
const currentColorIndex = MAIN_COLOR_NAMES.findIndex(
(color) => color === currentColor,
);
const nextColorIndex = (currentColorIndex + 1) % MAIN_COLOR_NAMES.length;
return MAIN_COLOR_NAMES[nextColorIndex];
};