diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index dda468a38..c246207ff 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1033,7 +1033,9 @@ export type User = { id: Scalars['UUID']['output']; lastName: Scalars['String']['output']; passwordHash?: Maybe; + /** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */ passwordResetToken?: Maybe; + /** @deprecated field migrated into the AppTokens Table ref: https://github.com/twentyhq/twenty/issues/5021 */ passwordResetTokenExpiresAt?: Maybe; supportUserHash?: Maybe; updatedAt: Scalars['DateTime']['output']; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts index 14302051e..669204037 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts @@ -69,18 +69,7 @@ export const variables = { disableMetadataField: { idToUpdate: fieldId, updatePayload: { isActive: false, label: undefined }, - }, - editMetadataField: { - idToUpdate: '2c43466a-fe9e-4005-8d08-c5836067aa6c', - updatePayload: { - defaultValue: undefined, - description: null, - icon: undefined, - label: 'New label', - name: 'newLabel', - options: undefined, - }, - }, + } }; const defaultResponseData = { diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFieldMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFieldMetadataItem.test.tsx index f74c788ff..17ca461f9 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFieldMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFieldMetadataItem.test.tsx @@ -68,17 +68,6 @@ const mocks = [ }, })), }, - { - request: { - query: queries.activateMetadataField, - variables: variables.editMetadataField, - }, - result: jest.fn(() => ({ - data: { - updateOneField: responseData.default, - }, - })), - }, ]; const Wrapper = ({ children }: { children: ReactNode }) => ( @@ -149,22 +138,4 @@ describe('useFieldMetadataItem', () => { }); }); }); - - it('should editMetadataField', async () => { - const { result } = renderHook(() => useFieldMetadataItem(), { - wrapper: Wrapper, - }); - - await act(async () => { - const res = await result.current.editMetadataField({ - id: fieldMetadataItem.id, - label: 'New label', - type: FieldMetadataType.Text, - }); - - expect(res.data).toEqual({ - updateOneField: responseData.default, - }); - }); - }); }); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts index 76200254f..e08e341c6 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts @@ -1,7 +1,3 @@ -import { v4 } from 'uuid'; - -import { FieldMetadataOption } from '@/object-metadata/types/FieldMetadataOption'; -import { getDefaultValueForBackend } from '@/object-metadata/utils/getDefaultValueForBackend'; import { Field } from '~/generated/graphql'; import { FieldMetadataItem } from '../types/FieldMetadataItem'; @@ -26,49 +22,12 @@ export const useFieldMetadataItem = () => { ) => { const formattedInput = formatFieldMetadataItemInput(input); - const defaultValue = getDefaultValueForBackend( - input.defaultValue ?? formattedInput.defaultValue, - input.type, - ); - return createOneFieldMetadataItem({ ...formattedInput, - defaultValue, objectMetadataId: input.objectMetadataId, type: input.type, - }); - }; - - const editMetadataField = ( - input: Pick< - Field, - | 'id' - | 'label' - | 'icon' - | 'description' - | 'defaultValue' - | 'type' - | 'options' - >, - ) => { - // In Edit mode, all options need an id, - // so we generate an id for newly created options. - const inputOptions = input.options?.map((option: FieldMetadataOption) => - option.id ? option : { ...option, id: v4() }, - ); - const formattedInput = formatFieldMetadataItemInput({ - ...input, - options: inputOptions, - }); - - const defaultValue = input.defaultValue ?? formattedInput.defaultValue; - - return updateOneFieldMetadataItem({ - fieldMetadataIdToUpdate: input.id, - updatePayload: { - ...formattedInput, - defaultValue, - }, + label: formattedInput.label ?? '', + name: formattedInput.name ?? '', }); }; @@ -92,6 +51,5 @@ export const useFieldMetadataItem = () => { createMetadataField, disableMetadataField, eraseMetadataField, - editMetadataField, }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts index 052caea6c..3217e5bae 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts @@ -12,7 +12,14 @@ import { FieldMetadataItem } from '../types/FieldMetadataItem'; export const useGetRelationMetadata = () => useRecoilCallback( ({ snapshot }) => - ({ fieldMetadataItem }: { fieldMetadataItem: FieldMetadataItem }) => { + ({ + fieldMetadataItem, + }: { + fieldMetadataItem: Pick< + FieldMetadataItem, + 'fromRelationMetadata' | 'toRelationMetadata' | 'type' + >; + }) => { if (fieldMetadataItem.type !== FieldMetadataType.Relation) return null; const relationMetadata = 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 a27866a2f..59a8f9b5c 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 @@ -77,7 +77,7 @@ describe('formatFieldMetadataItemInput', () => { value: 'OPTION_2', }, ], - defaultValue: 'OPTION_1', + defaultValue: "'OPTION_1'", }; const result = formatFieldMetadataItemInput(input); @@ -140,7 +140,7 @@ describe('formatFieldMetadataItemInput', () => { value: 'OPTION_2', }, ], - defaultValue: ['OPTION_1', 'OPTION_2'], + defaultValue: ["'OPTION_1'", "'OPTION_2'"], }; const result = formatFieldMetadataItemInput(input); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/validateMetadataLabel.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/validateMetadataLabel.test.ts deleted file mode 100644 index 7f29a921f..000000000 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/validateMetadataLabel.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel'; - -describe('validateMetadataLabel', () => { - it('should work as expected', () => { - const res = validateMetadataLabel('Pipeline Step'); - expect(res).toBe(true); - }); -}); 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 73aa34a30..ae0bfd685 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts @@ -1,6 +1,8 @@ import toSnakeCase from 'lodash.snakecase'; -import { Field, FieldMetadataType } from '~/generated-metadata/graphql'; +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'; @@ -21,25 +23,24 @@ export const getOptionValueFromLabel = (label: string) => { }; export const formatFieldMetadataItemInput = ( - input: Pick< - Field, - 'label' | 'icon' | 'description' | 'defaultValue' | 'options' - > & { type?: FieldMetadataType }, + input: Partial< + Pick< + FieldMetadataItem, + 'type' | 'label' | 'defaultValue' | 'icon' | 'description' + > + > & { options?: FieldMetadataOption[] }, ) => { - const options = input.options as FieldMetadataOption[]; + const options = input.options as FieldMetadataOption[] | undefined; let defaultValue = input.defaultValue; if (input.type === FieldMetadataType.MultiSelect) { - const defaultOptions = options?.filter((option) => option.isDefault); - if (isDefined(defaultOptions)) { - defaultValue = defaultOptions.map( - (defaultOption) => `${getOptionValueFromLabel(defaultOption.label)}`, - ); - } + 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)}` + ? getOptionValueFromLabel(defaultOption.label) : undefined; } @@ -59,12 +60,17 @@ export const formatFieldMetadataItemInput = ( } } + const label = input.label?.trim(); + return { - defaultValue, + defaultValue: + isDefined(defaultValue) && input.type + ? getDefaultValueForBackend(defaultValue, input.type) + : undefined, description: input.description?.trim() ?? null, icon: input.icon, - label: input.label.trim(), - name: formatMetadataLabelToMetadataNameOrThrows(input.label.trim()), + label, + name: label ? formatMetadataLabelToMetadataNameOrThrows(label) : undefined, options: options?.map((option, index) => ({ color: option.color, id: option.id, diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts index 94de1d58a..32f8b1f27 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts @@ -35,14 +35,14 @@ export const formatRelationMetadataInput = ( const { description: fromDescription, icon: fromIcon, - label: fromLabel, - name: fromName, + label: fromLabel = '', + name: fromName = '', } = formatFieldMetadataItemInput(fromField); const { description: toDescription, icon: toIcon, - label: toLabel, - name: toName, + label: toLabel = '', + name: toName = '', } = formatFieldMetadataItemInput(toField); return { diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts b/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts index fa404f9a9..a192905a2 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts @@ -13,7 +13,7 @@ export const getDefaultValueForBackend = ( currencyCode: `'${currencyDefaultValue.currencyCode}'` as CurrencyCode, } satisfies FieldCurrencyValue; } else if (fieldMetadataType === FieldMetadataType.Select) { - return `'${defaultValue}'`; + return defaultValue ? `'${defaultValue}'` : null; } else if (fieldMetadataType === FieldMetadataType.MultiSelect) { return defaultValue.map((value: string) => `'${value}'`); } diff --git a/packages/twenty-front/src/modules/object-metadata/utils/validateMetadataLabel.ts b/packages/twenty-front/src/modules/object-metadata/utils/validateMetadataLabel.ts deleted file mode 100644 index 74654c9eb..000000000 --- a/packages/twenty-front/src/modules/object-metadata/utils/validateMetadataLabel.ts +++ /dev/null @@ -1,4 +0,0 @@ -const metadataLabelValidationPattern = /^[^0-9].*$/; - -export const validateMetadataLabel = (value: string) => - !!value.match(metadataLabelValidationPattern); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx index bd8bb95a1..e733f12f2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx @@ -14,7 +14,7 @@ export const MultiSelectFieldDisplay = ({ const { fieldValues, fieldDefinition } = useMultiSelectField(); const selectedOptions = fieldValues - ? fieldDefinition.metadata.options.filter((option) => + ? fieldDefinition.metadata.options?.filter((option) => fieldValues.includes(option.value), ) : []; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx index 8d04ba863..aa1e69914 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFieldDisplay.tsx @@ -6,7 +6,11 @@ import { useRelationField } from '../../hooks/useRelationField'; export const RelationFieldDisplay = () => { const { fieldValue, fieldDefinition, maxWidth } = useRelationField(); - if (!fieldValue || !fieldDefinition) return null; + if ( + !fieldValue || + !fieldDefinition?.metadata.relationObjectMetadataNameSingular + ) + return null; return ( { const { fieldValue, fieldDefinition } = useSelectField(); - const selectedOption = fieldDefinition.metadata.options.find( + const selectedOption = fieldDefinition.metadata.options?.find( (option) => option.value === fieldValue, ); diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelDefaultValue.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelDefaultValue.tsx index c088ac1e7..ce586f911 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelDefaultValue.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsDataModelDefaultValue.tsx @@ -1,17 +1,26 @@ +import { Controller, useFormContext } from 'react-hook-form'; import styled from '@emotion/styled'; import { IconCheck, IconX } from 'twenty-ui'; +import { z } from 'zod'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { Select } from '@/ui/input/components/Select'; import { CardContent } from '@/ui/layout/card/components/CardContent'; -type SettingsDataModelDefaultValueFormProps = { - className?: string; - disabled?: boolean; - onChange?: (defaultValue: SettingsDataModelDefaultValue) => void; - value?: SettingsDataModelDefaultValue; -}; +// TODO: rename to SettingsDataModelFieldBooleanForm and move to settings/data-model/fields/forms/components -export type SettingsDataModelDefaultValue = any; +export const settingsDataModelFieldBooleanFormSchema = z.object({ + defaultValue: z.boolean(), +}); + +type SettingsDataModelFieldBooleanFormValues = z.infer< + typeof settingsDataModelFieldBooleanFormSchema +>; + +type SettingsDataModelFieldBooleanFormProps = { + className?: string; + fieldMetadataItem?: Pick; +}; const StyledContainer = styled(CardContent)` padding-bottom: ${({ theme }) => theme.spacing(3.5)}; @@ -26,34 +35,42 @@ const StyledLabel = styled.span` margin-top: ${({ theme }) => theme.spacing(1)}; `; -export const SettingsDataModelDefaultValueForm = ({ +export const SettingsDataModelFieldBooleanForm = ({ className, - disabled, - onChange, - value, -}: SettingsDataModelDefaultValueFormProps) => { + fieldMetadataItem, +}: SettingsDataModelFieldBooleanFormProps) => { + const { control } = useFormContext(); + + const initialValue = fieldMetadataItem?.defaultValue ?? true; + return ( Default Value - + )} /> ); diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx index 725b9a51e..c26d9fc28 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx @@ -1,39 +1,66 @@ +import { Controller, useFormContext } from 'react-hook-form'; +import { z } from 'zod'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; +import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes'; import { Select } from '@/ui/input/components/Select'; import { CardContent } from '@/ui/layout/card/components/CardContent'; -import { SETTINGS_FIELD_CURRENCY_CODES } from '../constants/SettingsFieldCurrencyCodes'; +// TODO: rename to SettingsDataModelFieldCurrencyForm and move to settings/data-model/fields/forms/components -export type SettingsObjectFieldCurrencyFormValues = { - currencyCode: CurrencyCode; -}; +export const settingsDataModelFieldCurrencyFormSchema = z.object({ + defaultValue: z.object({ + currencyCode: z.nativeEnum(CurrencyCode), + }), +}); -type SettingsObjectFieldCurrencyFormProps = { +type SettingsDataModelFieldCurrencyFormValues = z.infer< + typeof settingsDataModelFieldCurrencyFormSchema +>; + +type SettingsDataModelFieldCurrencyFormProps = { disabled?: boolean; - onChange: (values: Partial) => void; - values: SettingsObjectFieldCurrencyFormValues; + fieldMetadataItem?: Pick; }; -export const SettingsObjectFieldCurrencyForm = ({ - disabled, - onChange, - values, -}: SettingsObjectFieldCurrencyFormProps) => ( - - + )} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldRelationForm.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldRelationForm.tsx index 4df29780d..3252dde08 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldRelationForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldRelationForm.tsx @@ -1,28 +1,46 @@ +import { useMemo } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; import styled from '@emotion/styled'; import { useIcons } from 'twenty-ui'; +import { z } from 'zod'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation'; -import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel'; +import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema'; import { IconPicker } from '@/ui/input/components/IconPicker'; import { Select } from '@/ui/input/components/Select'; import { TextInput } from '@/ui/input/components/TextInput'; -import { Field } from '~/generated-metadata/graphql'; +import { RelationMetadataType } from '~/generated-metadata/graphql'; import { RELATION_TYPES } from '../constants/RelationTypes'; import { RelationType } from '../types/RelationType'; -export type SettingsObjectFieldRelationFormValues = { - field: Pick; - objectMetadataId: string; - type: RelationType; -}; +// TODO: rename to SettingsDataModelFieldRelationForm and move to settings/data-model/fields/forms/components -type SettingsObjectFieldRelationFormProps = { - disableFieldEdition?: boolean; - disableRelationEdition?: boolean; - onChange: (values: Partial) => void; - values: SettingsObjectFieldRelationFormValues; +export const settingsDataModelFieldRelationFormSchema = z.object({ + relation: z.object({ + field: fieldMetadataItemSchema.pick({ + icon: true, + label: true, + }), + objectMetadataId: z.string().uuid(), + type: z.enum( + Object.keys(RELATION_TYPES) as [RelationType, ...RelationType[]], + ), + }), +}); + +type SettingsDataModelFieldRelationFormValues = z.infer< + typeof settingsDataModelFieldRelationFormSchema +>; + +type SettingsDataModelFieldRelationFormProps = { + fieldMetadataItem?: Pick< + FieldMetadataItem, + 'fromRelationMetadata' | 'toRelationMetadata' | 'type' + >; }; const StyledContainer = styled.div` @@ -50,84 +68,119 @@ const StyledInputsContainer = styled.div` width: 100%; `; -export const SettingsObjectFieldRelationForm = ({ - disableFieldEdition, - disableRelationEdition, - onChange, - values, -}: SettingsObjectFieldRelationFormProps) => { +const RELATION_TYPE_OPTIONS = Object.entries(RELATION_TYPES) + .filter(([value]) => 'ONE_TO_ONE' !== value) + .map(([value, { label, Icon }]) => ({ + label, + value: value as RelationType, + Icon, + })); + +export const SettingsDataModelFieldRelationForm = ({ + fieldMetadataItem, +}: SettingsDataModelFieldRelationFormProps) => { + const { control } = + useFormContext(); const { getIcon } = useIcons(); - const { objectMetadataItems, findObjectMetadataItemById } = - useFilteredObjectMetadataItems(); + const { objectMetadataItems } = useFilteredObjectMetadataItems(); + + const getRelationMetadata = useGetRelationMetadata(); + const { + relationFieldMetadataItem, + relationType, + relationObjectMetadataItem, + } = + useMemo( + () => + fieldMetadataItem ? getRelationMetadata({ fieldMetadataItem }) : null, + [fieldMetadataItem, getRelationMetadata], + ) ?? {}; + + const disableFieldEdition = + relationFieldMetadataItem && !relationFieldMetadataItem.isCustom; + + const disableRelationEdition = !!relationFieldMetadataItem; const selectedObjectMetadataItem = - (values.objectMetadataId - ? findObjectMetadataItemById(values.objectMetadataId) - : undefined) || objectMetadataItems[0]; + relationObjectMetadataItem ?? objectMetadataItems[0]; return ( - + )} /> - ({ + label: objectMetadataItem.labelPlural, + value: objectMetadataItem.id, + Icon: getIcon(objectMetadataItem.icon), + }))} + onChange={onChange} + /> + )} /> Field on {selectedObjectMetadataItem?.labelPlural} - - onChange({ - field: { ...values.field, icon: value.iconKey }, - }) + ( + onChange(iconKey)} + variant="primary" + /> + )} /> - { - if (!value || validateMetadataLabel(value)) { - onChange({ - field: { ...values.field, label: value }, - }); - } - }} - fullWidth + ( + + )} /> 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 f60deeeaa..67633aaab 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 @@ -1,8 +1,12 @@ +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 { LightButton } from '@/ui/input/button/components/LightButton'; import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardFooter } from '@/ui/layout/card/components/CardFooter'; @@ -12,18 +16,32 @@ import { MAIN_COLOR_NAMES, ThemeColor, } from '@/ui/theme/constants/MainColorNames'; +import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema'; import { moveArrayItem } from '~/utils/array/moveArrayItem'; -import { SettingsObjectFieldSelectFormOption } from '../types/SettingsObjectFieldSelectFormOption'; - import { SettingsObjectFieldSelectFormOptionRow } from './SettingsObjectFieldSelectFormOptionRow'; -export type SettingsObjectFieldSelectFormValues = - SettingsObjectFieldSelectFormOption[]; +// TODO: rename to SettingsDataModelFieldSelectForm and move to settings/data-model/fields/forms/components -type SettingsObjectFieldSelectFormProps = { - onChange: (values: SettingsObjectFieldSelectFormValues) => void; - values: SettingsObjectFieldSelectFormValues; +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), +}); + +export type SettingsDataModelFieldSelectFormValues = z.infer< + typeof settingsDataModelFieldSelectFormSchema +>; + +type SettingsDataModelFieldSelectFormProps = { + fieldMetadataItem?: Pick; isMultiSelect?: boolean; }; @@ -58,12 +76,55 @@ const getNextColor = (currentColor: ThemeColor) => { return MAIN_COLOR_NAMES[nextColorIndex]; }; -export const SettingsObjectFieldSelectForm = ({ - onChange, - values, +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, -}: SettingsObjectFieldSelectFormProps) => { - const handleDragEnd = (result: DropResult) => { +}: SettingsDataModelFieldSelectFormProps) => { + const { control } = useFormContext(); + + const initialDefaultValueOptionIndexes = + getDefaultValueOptionIndexes(fieldMetadataItem); + + const initialValue = fieldMetadataItem?.options + ?.map((option, index) => ({ + ...option, + isDefault: initialDefaultValueOptionIndexes?.includes(index), + })) + .sort((optionA, optionB) => optionA.position - optionB.position); + + const handleDragEnd = ( + values: SettingsObjectFieldSelectFormOption[], + result: DropResult, + onChange: (options: SettingsObjectFieldSelectFormOption[]) => void, + ) => { if (!result.destination) return; const nextOptions = moveArrayItem(values, { @@ -74,27 +135,7 @@ export const SettingsObjectFieldSelectForm = ({ onChange(nextOptions); }; - const handleDefaultValueChange = ( - index: number, - option: SettingsObjectFieldSelectFormOption, - nextOption: SettingsObjectFieldSelectFormOption, - forceUniqueDefaultValue: boolean, - ) => { - const computeUniqueDefaultValue = - forceUniqueDefaultValue && option.isDefault !== nextOption.isDefault; - - const nextOptions = computeUniqueDefaultValue - ? values.map((value) => ({ - ...value, - isDefault: false, - })) - : [...values]; - - nextOptions.splice(index, 1, nextOption); - onChange(nextOptions); - }; - - const findNewLabel = () => { + const findNewLabel = (values: SettingsObjectFieldSelectFormOption[]) => { let optionIndex = values.length + 1; while (optionIndex < 100) { const newLabel = `Option ${optionIndex}`; @@ -107,65 +148,75 @@ export const SettingsObjectFieldSelectForm = ({ }; return ( - <> - - Options - - {values.map((option, index) => ( - ( + <> + + Options + handleDragEnd(options, result, onChange)} + draggableItems={ + <> + {options.map((option, index) => ( + { - handleDefaultValueChange( - index, - option, - nextOption, - !isMultiSelect, - ); - }} - onRemove={ - values.length > 1 - ? () => { - const nextOptions = [...values]; - nextOptions.splice(index, 1); - onChange(nextOptions); - } - : undefined + draggableId={option.value} + index={index} + isDragDisabled={options.length === 1} + itemComponent={ + { + 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} + /> } - option={option} /> - } - /> - ))} - - } - /> - - - - onChange([ - ...values, - { - color: getNextColor(values[values.length - 1].color), - label: findNewLabel(), - value: v4(), - }, - ]) - } - /> - - + ))} + + } + /> + + + + onChange([ + ...options, + { + color: getNextColor(options[options.length - 1].color), + label: findNewLabel(options), + value: v4(), + }, + ]) + } + /> + + + )} + /> ); }; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index 69a4accbe..42dc5aa13 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -1,20 +1,25 @@ +import { useFormContext } from 'react-hook-form'; import styled from '@emotion/styled'; +import { z } from 'zod'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { SettingsDataModelDefaultValueForm } from '@/settings/data-model/components/SettingsDataModelDefaultValue'; +import { + SettingsDataModelFieldBooleanForm, + settingsDataModelFieldBooleanFormSchema, +} from '@/settings/data-model/components/SettingsDataModelDefaultValue'; import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard'; import { - SettingsObjectFieldCurrencyForm, - SettingsObjectFieldCurrencyFormValues, + SettingsDataModelFieldCurrencyForm, + settingsDataModelFieldCurrencyFormSchema, } from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm'; import { - SettingsObjectFieldRelationForm, - SettingsObjectFieldRelationFormValues, + SettingsDataModelFieldRelationForm, + settingsDataModelFieldRelationFormSchema, } from '@/settings/data-model/components/SettingsObjectFieldRelationForm'; import { - SettingsObjectFieldSelectForm, - SettingsObjectFieldSelectFormValues, + SettingsDataModelFieldSelectForm, + settingsDataModelFieldSelectFormSchema, } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes'; import { @@ -23,26 +28,44 @@ import { } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -export type SettingsDataModelFieldSettingsFormValues = { - currency: SettingsObjectFieldCurrencyFormValues; - relation: SettingsObjectFieldRelationFormValues; - select: SettingsObjectFieldSelectFormValues; - multiSelect: SettingsObjectFieldSelectFormValues; - defaultValue: any; -}; +const booleanFieldFormSchema = z + .object({ type: z.literal(FieldMetadataType.Boolean) }) + .merge(settingsDataModelFieldBooleanFormSchema); + +const currencyFieldFormSchema = z + .object({ type: z.literal(FieldMetadataType.Currency) }) + .merge(settingsDataModelFieldCurrencyFormSchema); + +const relationFieldFormSchema = z + .object({ type: z.literal(FieldMetadataType.Relation) }) + .merge(settingsDataModelFieldRelationFormSchema); + +const selectFieldFormSchema = z + .object({ + type: z.enum([FieldMetadataType.Select, FieldMetadataType.MultiSelect]), + }) + .merge(settingsDataModelFieldSelectFormSchema); + +export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion( + 'type', + [ + booleanFieldFormSchema, + currencyFieldFormSchema, + relationFieldFormSchema, + selectFieldFormSchema, + ], +); + +type SettingsDataModelFieldSettingsFormValues = z.infer< + typeof settingsDataModelFieldSettingsFormSchema +>; type SettingsDataModelFieldSettingsFormCardProps = { disableCurrencyForm?: boolean; - onChange: (values: Partial) => void; - relationFieldMetadataItem?: Pick< - FieldMetadataItem, - 'id' | 'isCustom' | 'name' - >; - values: SettingsDataModelFieldSettingsFormValues; -} & Pick< - SettingsDataModelFieldPreviewCardProps, - 'fieldMetadataItem' | 'objectMetadataItem' ->; + fieldMetadataItem: Pick & + Partial>; + relationFieldMetadataItem?: FieldMetadataItem; +} & Pick; const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` display: grid; @@ -81,18 +104,23 @@ export const SettingsDataModelFieldSettingsFormCard = ({ disableCurrencyForm, fieldMetadataItem, objectMetadataItem, - onChange, relationFieldMetadataItem, - values, }: SettingsDataModelFieldSettingsFormCardProps) => { + const { watch: watchFormValue } = + useFormContext(); const { findObjectMetadataItemById } = useFilteredObjectMetadataItems(); if (!previewableTypes.includes(fieldMetadataItem.type)) return null; - const relationObjectMetadataItem = findObjectMetadataItemById( - values.relation.objectMetadataId, - ); - const relationTypeConfig = RELATION_TYPES[values.relation.type]; + const relationObjectMetadataId = watchFormValue('relation.objectMetadataId'); + const relationObjectMetadataItem = relationObjectMetadataId + ? findObjectMetadataItemById(relationObjectMetadataId) + : undefined; + + const relationType = watchFormValue('relation.type'); + const relationTypeConfig = relationType + ? RELATION_TYPES[relationType] + : undefined; return ( {fieldMetadataItem.type === FieldMetadataType.Relation && - !!relationObjectMetadataItem && ( + !!relationObjectMetadataItem && + !!relationTypeConfig && ( <> } form={ - fieldMetadataItem.type === FieldMetadataType.Currency ? ( - + ) : fieldMetadataItem.type === FieldMetadataType.Currency ? ( + - onChange({ - currency: { ...values.currency, ...nextCurrencyValues }, - }) - } + fieldMetadataItem={fieldMetadataItem} /> ) : fieldMetadataItem.type === FieldMetadataType.Relation ? ( - - onChange({ - relation: { ...values.relation, ...nextRelationValues }, - }) - } + - ) : fieldMetadataItem.type === FieldMetadataType.Select ? ( - - onChange({ select: nextSelectValues }) - } - /> - ) : fieldMetadataItem.type === FieldMetadataType.MultiSelect ? ( - - onChange({ multiSelect: nextMultiSelectValues }) - } - isMultiSelect={true} - /> - ) : fieldMetadataItem.type === FieldMetadataType.Boolean ? ( - - onChange({ defaultValue: nextValueDefaultValue }) + ) : fieldMetadataItem.type === FieldMetadataType.Select || + fieldMetadataItem.type === FieldMetadataType.MultiSelect ? ( + ) : undefined diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect.tsx index bcc3cbb6a..66e9f24e0 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect.tsx @@ -1,38 +1,47 @@ +import { Controller, useFormContext } from 'react-hook-form'; import omit from 'lodash.omit'; +import { z } from 'zod'; +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { SETTINGS_FIELD_TYPE_CONFIGS, SettingsFieldTypeConfig, } from '@/settings/data-model/constants/SettingsFieldTypeConfigs'; import { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType'; import { Select, SelectOption } from '@/ui/input/components/Select'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const settingsDataModelFieldTypeFormSchema = z.object({ + type: z.enum( + Object.keys(SETTINGS_FIELD_TYPE_CONFIGS) as [ + SettingsSupportedFieldType, + ...SettingsSupportedFieldType[], + ], + ), +}); + +type SettingsDataModelFieldTypeFormValues = z.infer< + typeof settingsDataModelFieldTypeFormSchema +>; type SettingsDataModelFieldTypeSelectProps = { className?: string; disabled?: boolean; excludedFieldTypes?: SettingsSupportedFieldType[]; - onChange?: ({ - type, - defaultValue, - }: { - type: SettingsSupportedFieldType; - defaultValue: any; - }) => void; - value?: SettingsSupportedFieldType; + fieldMetadataItem?: FieldMetadataItem; }; export const SettingsDataModelFieldTypeSelect = ({ className, disabled, excludedFieldTypes = [], - onChange, - value, + fieldMetadataItem, }: SettingsDataModelFieldTypeSelectProps) => { - const fieldTypeConfigs = omit( - SETTINGS_FIELD_TYPE_CONFIGS, - excludedFieldTypes, - ); + const { control } = useFormContext(); + + const fieldTypeConfigs: Partial< + Record + > = omit(SETTINGS_FIELD_TYPE_CONFIGS, excludedFieldTypes); + const fieldTypeOptions = Object.entries( fieldTypeConfigs, ).map>(([key, dataTypeConfig]) => ({ @@ -42,19 +51,25 @@ export const SettingsDataModelFieldTypeSelect = ({ })); return ( - + )} /> ); }; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldSettingsFormCard.stories.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldSettingsFormCard.stories.tsx index 0f1b09ca6..2cc6e9e34 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldSettingsFormCard.stories.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldSettingsFormCard.stories.tsx @@ -1,12 +1,8 @@ import { Meta, StoryObj } from '@storybook/react'; -import { fn } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui'; -import { fieldMetadataFormDefaultValues } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm'; -import { - FieldMetadataType, - RelationMetadataType, -} from '~/generated-metadata/graphql'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { FormProviderDecorator } from '~/testing/decorators/FormProviderDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -22,14 +18,6 @@ const fieldMetadataItem = mockedCompanyObjectMetadataItem.fields.find( ({ type }) => type === FieldMetadataType.Text, )!; -const defaultValues = { - currency: fieldMetadataFormDefaultValues.currency, - relation: fieldMetadataFormDefaultValues.relation, - select: fieldMetadataFormDefaultValues.select, - multiSelect: fieldMetadataFormDefaultValues.multiSelect, - defaultValue: fieldMetadataFormDefaultValues.defaultValue, -}; - const meta: Meta = { title: 'Modules/Settings/DataModel/Fields/Forms/SettingsDataModelFieldSettingsFormCard', @@ -39,12 +27,11 @@ const meta: Meta = { ComponentDecorator, ObjectMetadataItemsDecorator, SnackBarDecorator, + FormProviderDecorator, ], args: { fieldMetadataItem, objectMetadataItem: mockedCompanyObjectMetadataItem, - onChange: fn(), - values: defaultValues, }, parameters: { container: { width: 512 }, @@ -57,24 +44,14 @@ type Story = StoryObj; export const Default: Story = {}; -const relationFieldMetadataItem = mockedPersonObjectMetadataItem.fields.find( - ({ name }) => name === 'company', -)!; - export const WithRelationForm: Story = { args: { fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find( ({ name }) => name === 'people', ), - relationFieldMetadataItem, - values: { - ...defaultValues, - relation: { - field: relationFieldMetadataItem, - objectMetadataId: mockedPersonObjectMetadataItem.id, - type: RelationMetadataType.OneToMany, - }, - }, + relationFieldMetadataItem: mockedPersonObjectMetadataItem.fields.find( + ({ name }) => name === 'company', + )!, }, }; @@ -85,34 +62,5 @@ export const WithSelectForm: Story = { icon: 'IconBuildingFactory2', type: FieldMetadataType.Select, }, - values: { - ...defaultValues, - select: [ - { - color: 'pink', - isDefault: true, - label: '💊 Health', - value: 'HEALTH', - }, - { - color: 'purple', - label: '🏭 Industry', - value: 'INDUSTRY', - }, - { color: 'sky', label: '🤖 SaaS', value: 'SAAS' }, - { - color: 'turquoise', - label: '🌿 Green tech', - value: 'GREEN_TECH', - }, - { - color: 'yellow', - label: '🚲 Mobility', - value: 'MOBILITY', - }, - { color: 'green', label: '🌏 NGO', value: 'NGO' }, - ], - defaultValue: undefined, - }, }, }; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldTypeSelect.stories.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldTypeSelect.stories.tsx index 903383747..ff58bec33 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldTypeSelect.stories.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/__stories__/SettingsDataModelFieldTypeSelect.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; -import { expect, fn, userEvent, within } from '@storybook/test'; +import { expect, userEvent, within } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -12,10 +12,6 @@ const meta: Meta = { 'Modules/Settings/DataModel/Fields/Forms/SettingsDataModelFieldTypeSelect', component: SettingsDataModelFieldTypeSelect, decorators: [ComponentDecorator], - args: { - onChange: fn(), - value: FieldMetadataType.Text, - }, parameters: { container: { width: 512 }, msw: graphqlMocks, diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/__tests__/useFieldMetadataForm.test.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/__tests__/useFieldMetadataForm.test.ts deleted file mode 100644 index 4f9fb0361..000000000 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/__tests__/useFieldMetadataForm.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; - -import { FieldMetadataType } from '~/generated/graphql'; - -import { useFieldMetadataForm } from '../useFieldMetadataForm'; - -describe('useFieldMetadataForm', () => { - it('should initialize with default values', () => { - const { result } = renderHook(() => useFieldMetadataForm()); - - expect(result.current.isInitialized).toBe(false); - - act(() => { - result.current.initForm({}); - }); - - expect(result.current.isInitialized).toBe(true); - expect(result.current.formValues).toEqual({ - type: 'TEXT', - currency: { currencyCode: 'USD' }, - relation: { - type: 'ONE_TO_MANY', - objectMetadataId: '', - field: { label: '' }, - }, - defaultValue: null, - select: [ - { color: 'green', label: 'Option 1', value: expect.any(String) }, - ], - multiSelect: [ - { color: 'green', label: 'Option 1', value: expect.any(String) }, - ], - }); - }); - - it('should handle form changes', () => { - const { result } = renderHook(() => useFieldMetadataForm()); - - act(() => { - result.current.initForm({}); - }); - - expect(result.current.hasFieldFormChanged).toBe(false); - expect(result.current.hasRelationFormChanged).toBe(false); - expect(result.current.hasSelectFormChanged).toBe(false); - - act(() => { - result.current.handleFormChange({ type: FieldMetadataType.Number }); - }); - - expect(result.current.hasFieldFormChanged).toBe(true); - expect(result.current.hasRelationFormChanged).toBe(false); - expect(result.current.hasSelectFormChanged).toBe(false); - }); -}); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts deleted file mode 100644 index 7377473b1..000000000 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/hooks/useFieldMetadataForm.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { useState } from 'react'; -import { DeepPartial } from 'react-hook-form'; -import { v4 } from 'uuid'; -import { z } from 'zod'; - -import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; -import { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType'; -import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema'; -import { - FieldMetadataType, - RelationMetadataType, -} from '~/generated-metadata/graphql'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; - -import { SettingsDataModelFieldSettingsFormValues } from '../components/SettingsDataModelFieldSettingsFormCard'; - -type FormValues = { - defaultValue: any; - type: SettingsSupportedFieldType; -} & SettingsDataModelFieldSettingsFormValues; - -export const fieldMetadataFormDefaultValues: FormValues = { - type: FieldMetadataType.Text, - currency: { currencyCode: CurrencyCode.USD }, - relation: { - type: RelationMetadataType.OneToMany, - objectMetadataId: '', - field: { label: '' }, - }, - defaultValue: null, - select: [{ color: 'green', label: 'Option 1', value: v4() }], - multiSelect: [{ color: 'green', label: 'Option 1', value: v4() }], -}; -const relationTargetFieldSchema = z.object({ - description: z.string().optional(), - icon: z.string().startsWith('Icon'), - label: z.string().min(1), - defaultValue: z.any(), -}); -const fieldSchema = z.object({ - defaultValue: z.any(), - type: z.enum( - Object.values(FieldMetadataType) as [ - FieldMetadataType, - ...FieldMetadataType[], - ], - ), -}); - -const currencySchema = fieldSchema.merge( - z.object({ - type: z.literal(FieldMetadataType.Currency), - currency: z.object({ - currencyCode: z.nativeEnum(CurrencyCode), - }), - }), -); - -const relationSchema = fieldSchema.merge( - z.object({ - type: z.literal(FieldMetadataType.Relation), - relation: z.object({ - field: relationTargetFieldSchema, - objectMetadataId: z.string().uuid(), - type: z.enum([ - RelationMetadataType.OneToMany, - RelationMetadataType.OneToOne, - 'MANY_TO_ONE', - ]), - }), - }), -); - -const selectSchema = fieldSchema.merge( - z.object({ - type: z.literal(FieldMetadataType.Select), - select: z - .array( - z.object({ - color: themeColorSchema, - id: z.string().optional(), - isDefault: z.boolean().optional(), - label: z.string().min(1), - }), - ) - .nonempty(), - }), -); - -const multiSelectSchema = fieldSchema.merge( - z.object({ - type: z.literal(FieldMetadataType.MultiSelect), - multiSelect: z - .array( - z.object({ - color: themeColorSchema, - id: z.string().optional(), - isDefault: z.boolean().optional(), - label: z.string().min(1), - }), - ) - .nonempty(), - }), -); - -const { - Currency: _Currency, - Relation: _Relation, - Select: _Select, - MultiSelect: _MultiSelect, - ...otherFieldTypes -} = FieldMetadataType; - -type OtherFieldType = Exclude< - FieldMetadataType, - | FieldMetadataType.Currency - | FieldMetadataType.Relation - | FieldMetadataType.Select - | FieldMetadataType.MultiSelect ->; - -const otherFieldTypesSchema = fieldSchema.merge( - z.object({ - type: z.enum( - Object.values(otherFieldTypes) as [OtherFieldType, ...OtherFieldType[]], - ), - }), -); - -const schema = z.discriminatedUnion('type', [ - currencySchema, - relationSchema, - selectSchema, - multiSelectSchema, - otherFieldTypesSchema, -]); - -type PartialFormValues = Partial> & - DeepPartial>; - -export const useFieldMetadataForm = () => { - const [isInitialized, setIsInitialized] = useState(false); - const [initialFormValues, setInitialFormValues] = useState( - fieldMetadataFormDefaultValues, - ); - const [formValues, setFormValues] = useState( - fieldMetadataFormDefaultValues, - ); - const [hasFieldFormChanged, setHasFieldFormChanged] = useState(false); - const [hasCurrencyFormChanged, setHasCurrencyFormChanged] = useState(false); - const [hasRelationFormChanged, setHasRelationFormChanged] = useState(false); - const [hasSelectFormChanged, setHasSelectFormChanged] = useState(false); - const [hasMultiSelectFormChanged, setHasMultiSelectFormChanged] = - useState(false); - const [hasDefaultValueChanged, setHasDefaultValueFormChanged] = - useState(false); - const [validationResult, setValidationResult] = useState( - schema.safeParse(formValues), - ); - - const mergePartialValues = ( - previousValues: FormValues, - nextValues: PartialFormValues, - ): FormValues => ({ - ...previousValues, - ...nextValues, - currency: { ...previousValues.currency, ...nextValues.currency }, - relation: { - ...previousValues.relation, - ...nextValues.relation, - field: { - ...previousValues.relation?.field, - ...nextValues.relation?.field, - }, - }, - }); - - const initForm = (lazyInitialFormValues: PartialFormValues) => { - if (isInitialized) return; - - const mergedFormValues = mergePartialValues( - initialFormValues, - lazyInitialFormValues, - ); - - setInitialFormValues(mergedFormValues); - setFormValues(mergedFormValues); - setValidationResult(schema.safeParse(mergedFormValues)); - setIsInitialized(true); - }; - - const handleFormChange = (values: PartialFormValues) => { - const nextFormValues = mergePartialValues(formValues, values); - - setFormValues(nextFormValues); - setValidationResult(schema.safeParse(nextFormValues)); - - const { - currency: initialCurrencyFormValues, - relation: initialRelationFormValues, - select: initialSelectFormValues, - multiSelect: initialMultiSelectFormValues, - defaultValue: initialDefaultValue, - ...initialFieldFormValues - } = initialFormValues; - const { - currency: nextCurrencyFormValues, - relation: nextRelationFormValues, - select: nextSelectFormValues, - multiSelect: nextMultiSelectFormValues, - defaultValue: nextDefaultValue, - ...nextFieldFormValues - } = nextFormValues; - - setHasFieldFormChanged( - !isDeeplyEqual(initialFieldFormValues, nextFieldFormValues), - ); - setHasCurrencyFormChanged( - nextFieldFormValues.type === FieldMetadataType.Currency && - !isDeeplyEqual(initialCurrencyFormValues, nextCurrencyFormValues), - ); - setHasRelationFormChanged( - nextFieldFormValues.type === FieldMetadataType.Relation && - !isDeeplyEqual(initialRelationFormValues, nextRelationFormValues), - ); - setHasSelectFormChanged( - nextFieldFormValues.type === FieldMetadataType.Select && - !isDeeplyEqual(initialSelectFormValues, nextSelectFormValues), - ); - setHasMultiSelectFormChanged( - nextFieldFormValues.type === FieldMetadataType.MultiSelect && - !isDeeplyEqual(initialMultiSelectFormValues, nextMultiSelectFormValues), - ); - setHasDefaultValueFormChanged( - nextFieldFormValues.type === FieldMetadataType.Boolean && - !isDeeplyEqual(initialDefaultValue, nextDefaultValue), - ); - }; - - return { - formValues, - handleFormChange, - hasFieldFormChanged, - hasFormChanged: - hasFieldFormChanged || - hasCurrencyFormChanged || - hasRelationFormChanged || - hasSelectFormChanged || - hasMultiSelectFormChanged || - hasDefaultValueChanged, - hasRelationFormChanged, - hasSelectFormChanged, - hasMultiSelectFormChanged, - hasDefaultValueChanged, - initForm, - isInitialized, - isValid: validationResult.success, - validatedFormValues: validationResult.success - ? validationResult.data - : undefined, - }; -}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema.ts index 7e0e34fd0..3735fefc0 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema.ts @@ -1,3 +1,11 @@ -import { settingsDataModelFieldAboutFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm'; +import { z } from 'zod'; -export const settingsFieldFormSchema = settingsDataModelFieldAboutFormSchema; +import { settingsDataModelFieldAboutFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm'; +import { settingsDataModelFieldSettingsFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard'; +import { settingsDataModelFieldTypeFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect'; + +export const settingsFieldFormSchema = z + .object({}) + .merge(settingsDataModelFieldAboutFormSchema) + .merge(settingsDataModelFieldTypeFormSchema) + .and(settingsDataModelFieldSettingsFormSchema); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx index e41e18e34..4447cd827 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx @@ -8,7 +8,7 @@ import { FieldDisplay } from '@/object-record/record-field/components/FieldDispl import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; 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 { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect'; import { SettingsDataModelSetRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect'; import { useFieldPreview } from '@/settings/data-model/fields/preview/hooks/useFieldPreview'; @@ -24,7 +24,7 @@ export type SettingsDataModelFieldPreviewProps = { }; objectMetadataItem: ObjectMetadataItem; relationObjectMetadataItem?: ObjectMetadataItem; - selectOptions?: SettingsObjectFieldSelectFormValues; + selectOptions?: SettingsDataModelFieldSelectFormValues['options']; shrink?: boolean; withFieldLabel?: boolean; }; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreview.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreview.ts index 8f29de04e..0db454163 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreview.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreview.ts @@ -4,7 +4,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; -import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; import { getFieldDefaultPreviewValue } from '@/settings/data-model/utils/getFieldDefaultPreviewValue'; import { getFieldPreviewValueFromRecord } from '@/settings/data-model/utils/getFieldPreviewValueFromRecord'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -16,7 +16,7 @@ type UseFieldPreviewParams = { }; objectMetadataItem: ObjectMetadataItem; relationObjectMetadataItem?: ObjectMetadataItem; - selectOptions?: SettingsObjectFieldSelectFormValues; + selectOptions?: SettingsDataModelFieldSelectFormValues['options']; }; export const useFieldPreview = ({ diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx index 64db0318d..b7dfc9229 100644 --- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx @@ -3,7 +3,6 @@ import styled from '@emotion/styled'; import { z } from 'zod'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { validateMetadataLabel } from '@/object-metadata/utils/validateMetadataLabel'; import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema'; import { IconPicker } from '@/ui/input/components/IconPicker'; import { TextArea } from '@/ui/input/components/TextArea'; @@ -93,11 +92,7 @@ export const SettingsDataModelObjectAboutForm = ({ label={label} placeholder={placeholder} value={value} - onChange={(value) => { - if (!value || validateMetadataLabel(value)) { - onChange?.(value); - } - }} + onChange={onChange} disabled={disabled} fullWidth /> diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldDefaultPreviewValue.test.ts b/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldDefaultPreviewValue.test.ts index da48abd82..c9f1e1aae 100644 --- a/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldDefaultPreviewValue.test.ts +++ b/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldDefaultPreviewValue.test.ts @@ -1,5 +1,5 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; import { mockedCompanyObjectMetadataItem, mockedOpportunityObjectMetadataItem, @@ -16,8 +16,19 @@ describe('getFieldDefaultPreviewValue', () => { const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find( ({ name }) => name === 'stage', )!; - const selectOptions: SettingsObjectFieldSelectFormValues = - fieldMetadataItem.options ?? []; + const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [ + { + color: 'purple', + label: '🏭 Industry', + value: 'INDUSTRY', + }, + { + color: 'pink', + isDefault: true, + label: '💊 Health', + value: 'HEALTH', + }, + ]; // When const result = getFieldDefaultPreviewValue({ @@ -27,7 +38,7 @@ describe('getFieldDefaultPreviewValue', () => { }); // Then - expect(result).toEqual(selectOptions[0].value); + expect(result).toEqual(selectOptions[1].value); }); it('returns the first select option if no default option was found', () => { @@ -36,8 +47,18 @@ describe('getFieldDefaultPreviewValue', () => { const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find( ({ name }) => name === 'stage', )!; - const selectOptions: SettingsObjectFieldSelectFormValues = - fieldMetadataItem.options ?? []; + const selectOptions: SettingsDataModelFieldSelectFormValues['options'] = [ + { + color: 'purple' as const, + label: '🏭 Industry', + value: 'INDUSTRY', + }, + { + color: 'pink' as const, + label: '💊 Health', + value: 'HEALTH', + }, + ]; // When const result = getFieldDefaultPreviewValue({ diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldPreviewValueFromRecord.test.ts b/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldPreviewValueFromRecord.test.ts index ace590657..54a58ff45 100644 --- a/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldPreviewValueFromRecord.test.ts +++ b/packages/twenty-front/src/modules/settings/data-model/utils/__tests__/getFieldPreviewValueFromRecord.test.ts @@ -1,5 +1,5 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; import { mockedCompanyObjectMetadataItem, mockedOpportunityObjectMetadataItem, @@ -20,8 +20,34 @@ describe('getFieldPreviewValueFromRecord', () => { const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find( ({ name }) => name === 'stage', )!; - const selectOptions: SettingsObjectFieldSelectFormValues = - fieldMetadataItem.options ?? []; + 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({ @@ -44,8 +70,24 @@ describe('getFieldPreviewValueFromRecord', () => { const fieldMetadataItem = mockedOpportunityObjectMetadataItem.fields.find( ({ name }) => name === 'stage', )!; - const selectOptions: SettingsObjectFieldSelectFormValues = - fieldMetadataItem.options ?? []; + 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({ diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts index a9777ffda..9455fd588 100644 --- a/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldDefaultPreviewValue.ts @@ -2,7 +2,7 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; -import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -19,7 +19,7 @@ export const getFieldDefaultPreviewValue = ({ }; objectMetadataItem: ObjectMetadataItem; relationObjectMetadataItem?: ObjectMetadataItem; - selectOptions?: SettingsObjectFieldSelectFormValues; + selectOptions?: SettingsDataModelFieldSelectFormValues['options']; }) => { if ( fieldMetadataItem.type === FieldMetadataType.Select && diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/getFieldPreviewValueFromRecord.ts b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldPreviewValueFromRecord.ts index 9e72b8755..61b915764 100644 --- a/packages/twenty-front/src/modules/settings/data-model/utils/getFieldPreviewValueFromRecord.ts +++ b/packages/twenty-front/src/modules/settings/data-model/utils/getFieldPreviewValueFromRecord.ts @@ -1,6 +1,6 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { SettingsObjectFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; +import { SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/components/SettingsObjectFieldSelectForm'; import { FieldMetadataType } from '~/generated-metadata/graphql'; export const getFieldPreviewValueFromRecord = ({ @@ -10,7 +10,7 @@ export const getFieldPreviewValueFromRecord = ({ }: { record: ObjectRecord; fieldMetadataItem: Pick; - selectOptions?: SettingsObjectFieldSelectFormValues; + selectOptions?: SettingsDataModelFieldSelectFormValues['options']; }) => { const recordFieldValue = record[fieldMetadataItem.name]; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index bf724c434..8db486fb2 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -4,25 +4,27 @@ import { useNavigate, useParams } from 'react-router-dom'; import styled from '@emotion/styled'; import { zodResolver } from '@hookform/resolvers/zod'; import { isNonEmptyString } from '@sniptt/guards'; +import omit from 'lodash.omit'; +import pick from 'lodash.pick'; import { IconArchive, IconSettings } from 'twenty-ui'; +import { v4 } from 'uuid'; import { z } from 'zod'; import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; +import { useUpdateOneFieldMetadataItem } from '@/object-metadata/hooks/useUpdateOneFieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { formatFieldMetadataItemInput } from '@/object-metadata/utils/formatFieldMetadataItemInput'; import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug'; import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { SettingsObjectFieldCurrencyFormValues } from '@/settings/data-model/components/SettingsObjectFieldCurrencyForm'; import { SettingsDataModelFieldAboutForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm'; import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard'; import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect'; -import { useFieldMetadataForm } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm'; import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema'; -import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings'; import { AppPath } from '@/types/AppPath'; import { H2Title } from '@/ui/display/typography/components/H2Title'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -30,10 +32,7 @@ import { Button } from '@/ui/input/button/components/Button'; import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer'; import { Section } from '@/ui/layout/section/components/Section'; import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb'; -import { - FieldMetadataType, - RelationMetadataType, -} from '~/generated-metadata/graphql'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; type SettingsDataModelFieldEditFormValues = z.infer< typeof settingsFieldFormSchema @@ -66,117 +65,38 @@ export const SettingsObjectFieldEdit = () => { const activeObjectMetadataItem = findActiveObjectMetadataItemBySlug(objectSlug); - const { disableMetadataField, editMetadataField } = useFieldMetadataItem(); + const { disableMetadataField } = useFieldMetadataItem(); const activeMetadataField = activeObjectMetadataItem?.fields.find( (metadataField) => metadataField.isActive && getFieldSlug(metadataField) === fieldSlug, ); const getRelationMetadata = useGetRelationMetadata(); - const { - relationFieldMetadataItem, - relationObjectMetadataItem, - relationType, - } = + const { relationFieldMetadataItem } = useMemo( () => activeMetadataField - ? getRelationMetadata({ - fieldMetadataItem: activeMetadataField, - }) + ? getRelationMetadata({ fieldMetadataItem: activeMetadataField }) : null, [activeMetadataField, getRelationMetadata], ) ?? {}; + const { updateOneFieldMetadataItem } = useUpdateOneFieldMetadataItem(); + const formConfig = useForm({ mode: 'onTouched', resolver: zodResolver(settingsFieldFormSchema), }); - const { - formValues, - handleFormChange, - hasFieldFormChanged, - hasDefaultValueChanged, - hasFormChanged, - hasRelationFormChanged, - hasSelectFormChanged, - hasMultiSelectFormChanged, - initForm, - isInitialized, - isValid, - validatedFormValues, - } = useFieldMetadataForm(); - useEffect(() => { if (!activeObjectMetadataItem || !activeMetadataField) { navigate(AppPath.NotFound); - return; } + }, [activeMetadataField, activeObjectMetadataItem, navigate]); - const { defaultValue } = activeMetadataField; + if (!activeObjectMetadataItem || !activeMetadataField) return null; - const currencyDefaultValue = - activeMetadataField.type === FieldMetadataType.Currency - ? (defaultValue as SettingsObjectFieldCurrencyFormValues | undefined) - : undefined; - - const selectOptions = activeMetadataField.options?.map((option) => ({ - ...option, - isDefault: defaultValue === `'${option.value}'`, - })); - selectOptions?.sort( - (optionA, optionB) => optionA.position - optionB.position, - ); - - const multiSelectOptions = activeMetadataField.options?.map((option) => ({ - ...option, - isDefault: defaultValue?.includes(`'${option.value}'`) || false, - })); - multiSelectOptions?.sort( - (optionA, optionB) => optionA.position - optionB.position, - ); - - const fieldType = activeMetadataField.type; - const isFieldTypeSupported = isFieldTypeSupportedInSettings(fieldType); - - if (!isFieldTypeSupported) return; - - initForm({ - type: fieldType, - ...(currencyDefaultValue ? { currency: currencyDefaultValue } : {}), - relation: { - field: { - icon: relationFieldMetadataItem?.icon, - label: relationFieldMetadataItem?.label || '', - }, - objectMetadataId: relationObjectMetadataItem?.id || '', - type: relationType || RelationMetadataType.OneToMany, - }, - defaultValue: activeMetadataField.defaultValue, - ...(selectOptions?.length ? { select: selectOptions } : {}), - ...(multiSelectOptions?.length - ? { multiSelect: multiSelectOptions } - : {}), - }); - }, [ - activeMetadataField, - activeObjectMetadataItem, - initForm, - navigate, - relationFieldMetadataItem?.icon, - relationFieldMetadataItem?.label, - relationObjectMetadataItem?.id, - relationType, - ]); - - if (!isInitialized || !activeObjectMetadataItem || !activeMetadataField) - return null; - - const canSave = - formConfig.formState.isValid && - isValid && - (formConfig.formState.isDirty || hasFormChanged); + const canSave = formConfig.formState.isValid && formConfig.formState.isDirty; const isLabelIdentifier = isLabelIdentifierField({ fieldMetadataItem: activeMetadataField, @@ -184,43 +104,37 @@ export const SettingsObjectFieldEdit = () => { }); const handleSave = async () => { - if (!validatedFormValues) return; - const formValues = formConfig.getValues(); const { dirtyFields } = formConfig.formState; try { if ( - validatedFormValues.type === FieldMetadataType.Relation && + formValues.type === FieldMetadataType.Relation && isNonEmptyString(relationFieldMetadataItem?.id) && - hasRelationFormChanged + 'relation' in dirtyFields ) { - await editMetadataField({ - icon: validatedFormValues.relation.field.icon, - id: relationFieldMetadataItem?.id, - label: validatedFormValues.relation.field.label, - type: validatedFormValues.type, + await updateOneFieldMetadataItem({ + fieldMetadataIdToUpdate: relationFieldMetadataItem.id, + updatePayload: formValues.relation.field, }); } - if ( - Object.keys(dirtyFields).length > 0 || - hasFieldFormChanged || - hasSelectFormChanged || - hasMultiSelectFormChanged || - hasDefaultValueChanged - ) { - await editMetadataField({ - ...formValues, - id: activeMetadataField.id, - defaultValue: validatedFormValues.defaultValue, - type: validatedFormValues.type, - options: - validatedFormValues.type === FieldMetadataType.Select - ? validatedFormValues.select - : validatedFormValues.type === FieldMetadataType.MultiSelect - ? validatedFormValues.multiSelect - : undefined, + const otherDirtyFields = omit(dirtyFields, 'relation'); + + if (Object.keys(otherDirtyFields).length > 0) { + const formattedInput = pick( + formatFieldMetadataItemInput(formValues), + Object.keys(otherDirtyFields), + ); + + const options = formattedInput.options?.map((option) => ({ + ...option, + id: option.id ?? v4(), + })); + + await updateOneFieldMetadataItem({ + fieldMetadataIdToUpdate: activeMetadataField.id, + updatePayload: { ...formattedInput, options }, }); } @@ -281,28 +195,13 @@ export const SettingsObjectFieldEdit = () => { /> {!isLabelIdentifier && ( diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx index ef6e2a620..c0f8116f6 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx @@ -22,7 +22,6 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain import { SettingsDataModelFieldAboutForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldAboutForm'; import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard'; import { SettingsDataModelFieldTypeSelect } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect'; -import { useFieldMetadataForm } from '@/settings/data-model/fields/forms/hooks/useFieldMetadataForm'; import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema'; import { SettingsSupportedFieldType } from '@/settings/data-model/types/SettingsSupportedFieldType'; import { AppPath } from '@/types/AppPath'; @@ -51,25 +50,14 @@ export const SettingsObjectNewFieldStep2 = () => { const { objectSlug = '' } = useParams(); const { enqueueSnackBar } = useSnackBar(); - const { - findActiveObjectMetadataItemBySlug, - findObjectMetadataItemById, - findObjectMetadataItemByNamePlural, - } = useFilteredObjectMetadataItems(); + const { findActiveObjectMetadataItemBySlug, findObjectMetadataItemById } = + useFilteredObjectMetadataItems(); const activeObjectMetadataItem = findActiveObjectMetadataItemBySlug(objectSlug); const { createMetadataField } = useFieldMetadataItem(); const cache = useApolloClient().cache; - const { - formValues, - handleFormChange, - initForm, - isValid, - validatedFormValues, - } = useFieldMetadataForm(); - const formConfig = useForm({ mode: 'onTouched', resolver: zodResolver(settingsFieldFormSchema), @@ -78,23 +66,8 @@ export const SettingsObjectNewFieldStep2 = () => { useEffect(() => { if (!activeObjectMetadataItem) { navigate(AppPath.NotFound); - return; } - - initForm({ - relation: { - field: { icon: activeObjectMetadataItem.icon }, - objectMetadataId: - findObjectMetadataItemByNamePlural('people')?.id || '', - }, - }); - }, [ - activeObjectMetadataItem, - findObjectMetadataItemByNamePlural, - initForm, - - navigate, - ]); + }, [activeObjectMetadataItem, navigate]); const [objectViews, setObjectViews] = useState([]); const [relationObjectViews, setRelationObjectViews] = useState([]); @@ -116,12 +89,16 @@ export const SettingsObjectNewFieldStep2 = () => { }, }); + const relationObjectMetadataId = formConfig.watch( + 'relation.objectMetadataId', + ); + useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.View, - skip: !formValues.relation?.objectMetadataId, + skip: !relationObjectMetadataId, filter: { type: { eq: ViewType.Table }, - objectMetadataId: { eq: formValues.relation?.objectMetadataId }, + objectMetadataId: { eq: relationObjectMetadataId }, }, onCompleted: async (views) => { if (isUndefinedOrNull(views)) return; @@ -135,37 +112,40 @@ export const SettingsObjectNewFieldStep2 = () => { if (!activeObjectMetadataItem) return null; - const canSave = formConfig.formState.isValid && isValid; + const canSave = formConfig.formState.isValid; const handleSave = async () => { - if (!validatedFormValues) return; - const formValues = formConfig.getValues(); try { - if (validatedFormValues.type === FieldMetadataType.Relation) { + if ( + formValues.type === FieldMetadataType.Relation && + 'relation' in formValues + ) { + const { relation: relationFormValues, ...fieldFormValues } = formValues; + const createdRelation = await createOneRelationMetadata({ - relationType: validatedFormValues.relation.type, - field: pick(formValues, ['icon', 'label', 'description']), + relationType: relationFormValues.type, + field: pick(fieldFormValues, ['icon', 'label', 'description']), objectMetadataId: activeObjectMetadataItem.id, connect: { field: { - icon: validatedFormValues.relation.field.icon, - label: validatedFormValues.relation.field.label, + icon: relationFormValues.field.icon, + label: relationFormValues.field.label, }, - objectMetadataId: validatedFormValues.relation.objectMetadataId, + objectMetadataId: relationFormValues.objectMetadataId, }, }); const relationObjectMetadataItem = findObjectMetadataItemById( - validatedFormValues.relation.objectMetadataId, + relationFormValues.objectMetadataId, ); objectViews.map(async (view) => { const viewFieldToCreate = { viewId: view.id, fieldMetadataId: - validatedFormValues.relation.type === 'MANY_TO_ONE' + relationFormValues.type === 'MANY_TO_ONE' ? createdRelation.data?.createOneRelation.toFieldMetadataId : createdRelation.data?.createOneRelation.fromFieldMetadataId, position: activeObjectMetadataItem.fields.length, @@ -198,7 +178,7 @@ export const SettingsObjectNewFieldStep2 = () => { const viewFieldToCreate = { viewId: view.id, fieldMetadataId: - validatedFormValues.relation.type === 'MANY_TO_ONE' + relationFormValues.type === 'MANY_TO_ONE' ? createdRelation.data?.createOneRelation.fromFieldMetadataId : createdRelation.data?.createOneRelation.toFieldMetadataId, position: relationObjectMetadataItem?.fields.length, @@ -229,22 +209,17 @@ export const SettingsObjectNewFieldStep2 = () => { }); } else { const createdMetadataField = await createMetadataField({ - defaultValue: - validatedFormValues.type === FieldMetadataType.Currency - ? { - amountMicros: null, - currencyCode: validatedFormValues.currency.currencyCode, - } - : validatedFormValues.defaultValue, ...formValues, - objectMetadataId: activeObjectMetadataItem.id, - type: validatedFormValues.type, - options: - validatedFormValues.type === FieldMetadataType.Select - ? validatedFormValues.select - : validatedFormValues.type === FieldMetadataType.MultiSelect - ? validatedFormValues.multiSelect + defaultValue: + formValues.type === FieldMetadataType.Currency + ? { + ...formValues.defaultValue, + amountMicros: null, + } + : 'defaultValue' in formValues + ? formValues.defaultValue : undefined, + objectMetadataId: activeObjectMetadataItem.id, }); objectViews.map(async (view) => { @@ -335,24 +310,14 @@ export const SettingsObjectNewFieldStep2 = () => { />