diff --git a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataOption.ts b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataOption.ts deleted file mode 100644 index 30d3d1eaafb..00000000000 --- a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataOption.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ThemeColor } from '@/ui/theme/constants/MainColorNames'; - -export type FieldMetadataOption = { - color?: ThemeColor; - id?: string; - isDefault?: boolean; - label: string; -}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts index 59a8f9b5c95..018723c04a6 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts @@ -1,59 +1,33 @@ +import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { - 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); - }); -}); +import { formatFieldMetadataItemInput } from '../formatFieldMetadataItemInput'; describe('formatFieldMetadataItemInput', () => { 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 = { + defaultValue: "'OPTION_1'", label: 'Example Label', icon: 'example-icon', type: FieldMetadataType.Select, description: 'Example description', - options: [ - { id: '1', label: 'Option 1', color: 'red' as const, isDefault: true }, - { id: '2', label: 'Option 2', color: 'blue' as const }, - ], + options, }; const expected = { @@ -61,22 +35,7 @@ describe('formatFieldMetadataItemInput', () => { icon: 'example-icon', label: 'Example Label', name: 'exampleLabel', - 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', - }, - ], + options, defaultValue: "'OPTION_1'", }; @@ -108,15 +67,29 @@ describe('formatFieldMetadataItemInput', () => { }); 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 = { + defaultValue: ["'OPTION_1'", "'OPTION_2'"], label: 'Example Label', icon: 'example-icon', type: FieldMetadataType.MultiSelect, description: 'Example description', - options: [ - { id: '1', label: 'Option 1', color: 'red' as const, isDefault: true }, - { id: '2', label: 'Option 2', color: 'blue' as const, isDefault: true }, - ], + options, }; const expected = { @@ -124,22 +97,7 @@ describe('formatFieldMetadataItemInput', () => { icon: 'example-icon', label: 'Example Label', name: 'exampleLabel', - 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', - }, - ], + options, defaultValue: ["'OPTION_1'", "'OPTION_2'"], }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts index ae0bfd68501..cbaae2cd51f 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts @@ -1,82 +1,22 @@ -import toSnakeCase from 'lodash.snakecase'; - 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 { 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 = ( input: Partial< Pick< 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(); return { - defaultValue: - isDefined(defaultValue) && input.type - ? getDefaultValueForBackend(defaultValue, input.type) - : undefined, + defaultValue: input.defaultValue, description: input.description?.trim() ?? null, icon: input.icon, label, name: label ? formatMetadataLabelToMetadataNameOrThrows(label) : undefined, - options: options?.map((option, index) => ({ - color: option.color, - id: option.id, - label: option.label.trim(), - position: index, - value: getOptionValueFromLabel(option.label), - })), + options: input.options, }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts b/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts deleted file mode 100644 index a192905a21e..00000000000 --- a/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts +++ /dev/null @@ -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; -}; diff --git a/packages/twenty-front/src/modules/object-metadata/validation-schemas/selectOptionsSchema.ts b/packages/twenty-front/src/modules/object-metadata/validation-schemas/selectOptionsSchema.ts new file mode 100644 index 00000000000..8ba48183631 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/validation-schemas/selectOptionsSchema.ts @@ -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; + +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', + }, + ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/currencyCodeSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/currencyCodeSchema.ts new file mode 100644 index 00000000000..2f5e7bff041 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/currencyCodeSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; + +export const currencyCodeSchema = z.nativeEnum(CurrencyCode); diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectForm.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectForm.tsx index 67633aaabcd..12249ad5623 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectForm.tsx @@ -2,47 +2,54 @@ import { Controller, useFormContext } from 'react-hook-form'; import styled from '@emotion/styled'; import { DropResult } from '@hello-pangea/dnd'; import { IconPlus } from 'twenty-ui'; -import { v4 } from 'uuid'; import { z } from 'zod'; -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { SettingsObjectFieldSelectFormOption } from '@/settings/data-model/types/SettingsObjectFieldSelectFormOption'; +import { + 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 { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardFooter } from '@/ui/layout/card/components/CardFooter'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; -import { - MAIN_COLOR_NAMES, - ThemeColor, -} from '@/ui/theme/constants/MainColorNames'; -import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { moveArrayItem } from '~/utils/array/moveArrayItem'; +import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString'; +import { simpleQuotesStringSchema } from '~/utils/validation-schemas/simpleQuotesStringSchema'; import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow'; // TODO: rename to SettingsDataModelFieldSelectForm and move to settings/data-model/fields/forms/components export const settingsDataModelFieldSelectFormSchema = z.object({ - options: z - .array( - z.object({ - color: themeColorSchema, - value: z.string(), - isDefault: z.boolean().optional(), - label: z.string().min(1), - }), - ) - .min(1), + defaultValue: simpleQuotesStringSchema.nullable(), + options: selectOptionsSchema, }); +export const settingsDataModelFieldMultiSelectFormSchema = z.object({ + defaultValue: z.array(simpleQuotesStringSchema).nullable(), + options: selectOptionsSchema, +}); + +const selectOrMultiSelectFormSchema = z.union([ + settingsDataModelFieldSelectFormSchema, + settingsDataModelFieldMultiSelectFormSchema, +]); + export type SettingsDataModelFieldSelectFormValues = z.infer< - typeof settingsDataModelFieldSelectFormSchema + typeof selectOrMultiSelectFormSchema >; type SettingsDataModelFieldSelectFormProps = { - fieldMetadataItem?: Pick; - isMultiSelect?: boolean; + fieldMetadataItem: Pick< + FieldMetadataItem, + 'defaultValue' | 'options' | 'type' + >; }; const StyledContainer = styled(CardContent)` @@ -68,155 +75,178 @@ const StyledButton = styled(LightButton)` 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?.options?.reduce((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 = ({ fieldMetadataItem, - isMultiSelect = false, }: SettingsDataModelFieldSelectFormProps) => { - const { control } = useFormContext(); - - const initialDefaultValueOptionIndexes = - getDefaultValueOptionIndexes(fieldMetadataItem); + const { initialDefaultValue, initialOptions } = + useSelectSettingsFormInitialValues({ fieldMetadataItem }); - const initialValue = fieldMetadataItem?.options - ?.map((option, index) => ({ - ...option, - isDefault: initialDefaultValueOptionIndexes?.includes(index), - })) - .sort((optionA, optionB) => optionA.position - optionB.position); + const { + control, + setValue: setFormValue, + watch: watchFormValue, + getValues, + } = useFormContext(); const handleDragEnd = ( - values: SettingsObjectFieldSelectFormOption[], + values: FieldMetadataItemOption[], result: DropResult, - onChange: (options: SettingsObjectFieldSelectFormOption[]) => void, + onChange: (options: FieldMetadataItemOption[]) => void, ) => { if (!result.destination) return; const nextOptions = moveArrayItem(values, { fromIndex: result.source.index, toIndex: result.destination.index, - }); + }).map((option, index) => ({ ...option, position: index })); onChange(nextOptions); }; - const findNewLabel = (values: SettingsObjectFieldSelectFormOption[]) => { - let optionIndex = values.length + 1; - while (optionIndex < 100) { - const newLabel = `Option ${optionIndex}`; - if (!values.map((value) => value.label).includes(newLabel)) { - return newLabel; - } - optionIndex += 1; + const isOptionDefaultValue = ( + optionValue: FieldMetadataItemOption['value'], + ) => + isSelectOptionDefaultValue(optionValue, { + type: fieldMetadataItem.type, + defaultValue: watchFormValue('defaultValue'), + }); + + 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 ( - ( - <> - - Options - handleDragEnd(options, result, onChange)} - draggableItems={ - <> - {options.map((option, index) => ( - { - const nextOptions = - isMultiSelect || !nextOption.isDefault - ? [...options] - : // Reset simple Select default option before setting the new one - options.map( - (value) => ({ ...value, isDefault: false }), - ); - nextOptions.splice(index, 1, nextOption); - onChange(nextOptions); - }} - onRemove={ - options.length > 1 - ? () => { - const nextOptions = [...options]; - nextOptions.splice(index, 1); - onChange(nextOptions); - } - : undefined - } - option={option} - /> - } - /> - ))} - - } - /> - - - - onChange([ - ...options, - { - color: getNextColor(options[options.length - 1].color), - label: findNewLabel(options), - value: v4(), - }, - ]) - } - /> - - - )} - /> + <> + <>} + /> + ( + <> + + Options + handleDragEnd(options, result, onChange)} + draggableItems={ + <> + {options.map((option, index) => ( + { + const nextOptions = [...options]; + nextOptions.splice(index, 1, nextOption); + onChange(nextOptions); + + // Update option value in defaultValue if value has changed + if ( + nextOption.value !== option.value && + isOptionDefaultValue(option.value) + ) { + handleRemoveOptionAsDefault(option.value); + handleSetOptionAsDefault(nextOption.value); + } + }} + onRemove={ + options.length > 1 + ? () => { + const nextOptions = [...options]; + nextOptions.splice(index, 1); + onChange(nextOptions); + } + : undefined + } + isDefault={isOptionDefaultValue(option.value)} + onSetAsDefault={() => + handleSetOptionAsDefault(option.value) + } + onRemoveAsDefault={() => + handleRemoveOptionAsDefault(option.value) + } + /> + } + /> + ))} + + } + /> + + + + onChange([...options, generateNewSelectOption(options)]) + } + /> + + + )} + /> + ); }; diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectFormOptionRow.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectFormOptionRow.tsx index 4a19f8727b0..a99b5ebed83 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectFormOptionRow.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldSelectFormOptionRow.tsx @@ -10,6 +10,8 @@ import { } from 'twenty-ui'; 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 { LightIconButton } from '@/ui/input/button/components/LightIconButton'; 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 { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames'; -import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption'; - type SettingsObjectFieldSelectFormOptionRowProps = { className?: string; isDefault?: boolean; - onChange: (value: SettingsObjectFieldSelectFormOption) => void; + onChange: (value: FieldMetadataItemOption) => void; onRemove?: () => void; - option: SettingsObjectFieldSelectFormOption; + onSetAsDefault?: () => void; + onRemoveAsDefault?: () => void; + option: FieldMetadataItemOption; }; const StyledRow = styled.div` @@ -58,6 +60,8 @@ export const SettingsObjectFieldSelectFormOptionRow = ({ isDefault, onChange, onRemove, + onSetAsDefault, + onRemoveAsDefault, option, }: SettingsObjectFieldSelectFormOptionRowProps) => { const theme = useTheme(); @@ -106,7 +110,9 @@ export const SettingsObjectFieldSelectFormOptionRow = ({ /> onChange({ ...option, label })} + onChange={(label) => + onChange({ ...option, label, value: getOptionValueFromLabel(label) }) + } RightIcon={isDefault ? IconCheck : undefined} /> { - onChange({ ...option, isDefault: false }); + onRemoveAsDefault?.(); closeActionsDropdown(); }} /> @@ -133,7 +139,7 @@ export const SettingsObjectFieldSelectFormOptionRow = ({ LeftIcon={IconCheck} text="Set as default" onClick={() => { - onChange({ ...option, isDefault: true }); + onSetAsDefault?.(); closeActionsDropdown(); }} /> diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldCurrencyForm.tsx similarity index 71% rename from packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx rename to packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldCurrencyForm.tsx index c26d9fc283e..d89d447acba 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldCurrencyForm.tsx @@ -3,15 +3,22 @@ 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'; - -// TODO: rename to SettingsDataModelFieldCurrencyForm and move to settings/data-model/fields/forms/components +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: 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( ([value, { label, Icon }]) => ({ label, - value: value as CurrencyCode, + value: applySimpleQuotesToString(value), Icon, }), ); @@ -48,7 +55,7 @@ export const SettingsDataModelFieldCurrencyForm = ({ ( ( + value: Input, +) => + (simpleQuotesStringSchema.safeParse(value).success + ? value.slice(1, -1) + : value) as Input extends `'${infer Output}'` ? Output : Input; diff --git a/packages/twenty-front/src/utils/validation-schemas/__tests__/simpleQuotesStringSchema.test.ts b/packages/twenty-front/src/utils/validation-schemas/__tests__/simpleQuotesStringSchema.test.ts new file mode 100644 index 00000000000..6ceeaa71a3a --- /dev/null +++ b/packages/twenty-front/src/utils/validation-schemas/__tests__/simpleQuotesStringSchema.test.ts @@ -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).error.errors).toEqual([ + { + code: 'custom', + message: 'String should be wrapped in simple quotes', + path: [], + }, + ]); + }); +}); diff --git a/packages/twenty-front/src/utils/validation-schemas/simpleQuotesStringSchema.ts b/packages/twenty-front/src/utils/validation-schemas/simpleQuotesStringSchema.ts new file mode 100644 index 00000000000..62f4a667ac7 --- /dev/null +++ b/packages/twenty-front/src/utils/validation-schemas/simpleQuotesStringSchema.ts @@ -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', + }, + ); diff --git a/packages/twenty-ui/__mocks__/imageMock.js b/packages/twenty-ui/__mocks__/imageMock.js new file mode 100644 index 00000000000..602eb23ee2e --- /dev/null +++ b/packages/twenty-ui/__mocks__/imageMock.js @@ -0,0 +1 @@ +export default 'test-file-stub'; diff --git a/packages/twenty-ui/jest.config.ts b/packages/twenty-ui/jest.config.ts index a51f9029889..9d295020eab 100644 --- a/packages/twenty-ui/jest.config.ts +++ b/packages/twenty-ui/jest.config.ts @@ -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', preset: '../../jest.preset.js', setupFilesAfterEnv: ['./setupTests.ts'], @@ -15,7 +20,14 @@ export default { }, ], }, + moduleNameMapper: { + '\\.(jpg|jpeg|png|gif|webp|svg|svg\\?react)$': + '/__mocks__/imageMock.js', + ...pathsToModuleNameMapper(tsConfig.compilerOptions.paths), + }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageDirectory: './coverage', }; + +export default jestConfig; diff --git a/packages/twenty-ui/src/theme/index.ts b/packages/twenty-ui/src/theme/index.ts index e71efe68363..883ecca32b4 100644 --- a/packages/twenty-ui/src/theme/index.ts +++ b/packages/twenty-ui/src/theme/index.ts @@ -32,3 +32,4 @@ export * from './constants/ThemeDark'; export * from './constants/ThemeLight'; export * from './provider/ThemeProvider'; export * from './types/ThemeType'; +export * from './utils/getNextThemeColor'; diff --git a/packages/twenty-ui/src/theme/utils/__tests__/getNextThemeColor.test.ts b/packages/twenty-ui/src/theme/utils/__tests__/getNextThemeColor.test.ts new file mode 100644 index 00000000000..b87e65b1fe3 --- /dev/null +++ b/packages/twenty-ui/src/theme/utils/__tests__/getNextThemeColor.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-ui/src/theme/utils/getNextThemeColor.ts b/packages/twenty-ui/src/theme/utils/getNextThemeColor.ts new file mode 100644 index 00000000000..40af82199de --- /dev/null +++ b/packages/twenty-ui/src/theme/utils/getNextThemeColor.ts @@ -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]; +};