#6094 Prevent creating a custom field with an existing name (#6100)

Fixes #6094 
Description: Added logic inside SettingsObjectNewFieldStep2.tsx to
prevent form submission
Current Behaviours:
<img width="947" alt="Screenshot 2024-07-03 at 1 45 31 PM"
src="https://github.com/twentyhq/twenty/assets/95612797/bef54bc4-fc83-48f3-894a-34445ec64723">

---------

Co-authored-by: Marie Stoppa <marie.stoppa@essec.edu>
This commit is contained in:
Deval Minocha
2024-07-17 15:13:57 +05:30
committed by GitHub
parent 37ae99390e
commit 94c2358c89
11 changed files with 212 additions and 151 deletions

View File

@ -6,7 +6,7 @@ describe('metadataLabelSchema', () => {
const validMetadataLabel = 'Option 1'; const validMetadataLabel = 'Option 1';
// When // When
const result = metadataLabelSchema.parse(validMetadataLabel); const result = metadataLabelSchema().parse(validMetadataLabel);
// Then // Then
expect(result).toEqual(validMetadataLabel); expect(result).toEqual(validMetadataLabel);
@ -16,7 +16,7 @@ describe('metadataLabelSchema', () => {
const validMetadataLabel = 'עִבְרִי'; const validMetadataLabel = 'עִבְרִי';
// When // When
const result = metadataLabelSchema.parse(validMetadataLabel); const result = metadataLabelSchema().parse(validMetadataLabel);
// Then // Then
expect(result).toEqual(validMetadataLabel); expect(result).toEqual(validMetadataLabel);

View File

@ -10,97 +10,99 @@ import {
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema'; import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema';
export const fieldMetadataItemSchema = z.object({ export const fieldMetadataItemSchema = (existingLabels?: string[]) => {
__typename: z.literal('field').optional(), return z.object({
createdAt: z.string().datetime(), __typename: z.literal('field').optional(),
defaultValue: z.any().optional(), createdAt: z.string().datetime(),
description: z.string().trim().nullable().optional(), defaultValue: z.any().optional(),
fromRelationMetadata: z description: z.string().trim().nullable().optional(),
.object({ fromRelationMetadata: z
__typename: z.literal('relation').optional(), .object({
id: z.string().uuid(), __typename: z.literal('relation').optional(),
relationType: z.nativeEnum(RelationMetadataType),
toFieldMetadataId: z.string().uuid(),
toObjectMetadata: z.object({
__typename: z.literal('object').optional(),
dataSourceId: z.string().uuid(),
id: z.string().uuid(), id: z.string().uuid(),
isRemote: z.boolean(), relationType: z.nativeEnum(RelationMetadataType),
isSystem: z.boolean(), toFieldMetadataId: z.string().uuid(),
namePlural: z.string().trim().min(1), toObjectMetadata: z.object({
nameSingular: z.string().trim().min(1), __typename: z.literal('object').optional(),
}), dataSourceId: z.string().uuid(),
}) id: z.string().uuid(),
.nullable() isRemote: z.boolean(),
.optional(), isSystem: z.boolean(),
icon: z.string().startsWith('Icon').trim().nullable(), namePlural: z.string().trim().min(1),
id: z.string().uuid(), nameSingular: z.string().trim().min(1),
isActive: z.boolean(), }),
isCustom: z.boolean(), })
isNullable: z.boolean(), .nullable()
isSystem: z.boolean(), .optional(),
label: metadataLabelSchema, icon: z.string().startsWith('Icon').trim().nullable(),
name: camelCaseStringSchema, id: z.string().uuid(),
options: z isActive: z.boolean(),
.array( isCustom: z.boolean(),
z.object({ isNullable: z.boolean(),
color: themeColorSchema, isSystem: z.boolean(),
label: metadataLabelSchema(existingLabels),
name: camelCaseStringSchema,
options: z
.array(
z.object({
color: themeColorSchema,
id: z.string().uuid(),
label: z.string().trim().min(1),
position: z.number(),
value: z.string().trim().min(1),
}),
)
.nullable()
.optional(),
relationDefinition: z
.object({
__typename: z.literal('RelationDefinition').optional(),
relationId: z.string().uuid(),
direction: z.nativeEnum(RelationDefinitionType),
sourceFieldMetadata: z.object({
__typename: z.literal('field').optional(),
id: z.string().uuid(),
name: z.string().trim().min(1),
}),
sourceObjectMetadata: z.object({
__typename: z.literal('object').optional(),
id: z.string().uuid(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
targetFieldMetadata: z.object({
__typename: z.literal('field').optional(),
id: z.string().uuid(),
name: z.string().trim().min(1),
}),
targetObjectMetadata: z.object({
__typename: z.literal('object').optional(),
id: z.string().uuid(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
})
.nullable()
.optional(),
toRelationMetadata: z
.object({
__typename: z.literal('relation').optional(),
id: z.string().uuid(), id: z.string().uuid(),
label: z.string().trim().min(1), relationType: z.nativeEnum(RelationMetadataType),
position: z.number(), fromFieldMetadataId: z.string().uuid(),
value: z.string().trim().min(1), fromObjectMetadata: z.object({
}), __typename: z.literal('object').optional(),
) id: z.string().uuid(),
.nullable() dataSourceId: z.string().uuid(),
.optional(), isRemote: z.boolean(),
relationDefinition: z isSystem: z.boolean(),
.object({ namePlural: z.string().trim().min(1),
__typename: z.literal('RelationDefinition').optional(), nameSingular: z.string().trim().min(1),
relationId: z.string().uuid(), }),
direction: z.nativeEnum(RelationDefinitionType), })
sourceFieldMetadata: z.object({ .nullable()
__typename: z.literal('field').optional(), .optional(),
id: z.string().uuid(), type: z.nativeEnum(FieldMetadataType),
name: z.string().trim().min(1), updatedAt: z.string().datetime(),
}), }) satisfies z.ZodType<FieldMetadataItem>;
sourceObjectMetadata: z.object({ };
__typename: z.literal('object').optional(),
id: z.string().uuid(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
targetFieldMetadata: z.object({
__typename: z.literal('field').optional(),
id: z.string().uuid(),
name: z.string().trim().min(1),
}),
targetObjectMetadata: z.object({
__typename: z.literal('object').optional(),
id: z.string().uuid(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
})
.nullable()
.optional(),
toRelationMetadata: z
.object({
__typename: z.literal('relation').optional(),
id: z.string().uuid(),
relationType: z.nativeEnum(RelationMetadataType),
fromFieldMetadataId: z.string().uuid(),
fromObjectMetadata: z.object({
__typename: z.literal('object').optional(),
id: z.string().uuid(),
dataSourceId: z.string().uuid(),
isRemote: z.boolean(),
isSystem: z.boolean(),
namePlural: z.string().trim().min(1),
nameSingular: z.string().trim().min(1),
}),
})
.nullable()
.optional(),
type: z.nativeEnum(FieldMetadataType),
updatedAt: z.string().datetime(),
}) satisfies z.ZodType<FieldMetadataItem>;

View File

@ -1,23 +1,42 @@
import { errors } from '@/settings/data-model/fields/forms/utils/errorMessages';
import { z } from 'zod'; import { z } from 'zod';
import { METADATA_LABEL_VALID_PATTERN } from '~/pages/settings/data-model/constants/MetadataLabelValidPattern'; import { METADATA_LABEL_VALID_PATTERN } from '~/pages/settings/data-model/constants/MetadataLabelValidPattern';
import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils'; import { computeMetadataNameFromLabelOrThrow } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
export const metadataLabelSchema = (existingLabels?: string[]) => {
export const metadataLabelSchema = z return z
.string() .string()
.trim() .trim()
.min(1) .min(1, errors.LabelEmpty)
.regex(METADATA_LABEL_VALID_PATTERN) .regex(METADATA_LABEL_VALID_PATTERN, errors.LabelNotFormattable)
.refine( .refine(
(label) => { (label) => {
try { try {
computeMetadataNameFromLabelOrThrow(label); computeMetadataNameFromLabelOrThrow(label);
return true; return true;
} catch (error) { } catch (error) {
return false; return false;
} }
}, },
{ {
message: 'Label is not formattable', message: errors.LabelNotFormattable,
}, },
); // allows non-latin char )
.refine(
(label) => {
try {
if (!existingLabels || !label?.length) {
return true;
}
return !existingLabels.includes(
computeMetadataNameFromLabelOrThrow(label),
);
} catch (error) {
return false;
}
},
{
message: errors.LabelNotUnique,
},
);
};

View File

@ -10,7 +10,7 @@ export const objectMetadataItemSchema = z.object({
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
dataSourceId: z.string().uuid(), dataSourceId: z.string().uuid(),
description: z.string().trim().nullable().optional(), description: z.string().trim().nullable().optional(),
fields: z.array(fieldMetadataItemSchema), fields: z.array(fieldMetadataItemSchema()),
icon: z.string().startsWith('Icon').trim(), icon: z.string().startsWith('Icon').trim(),
id: z.string().uuid(), id: z.string().uuid(),
imageIdentifierFieldMetadataId: z.string().uuid().nullable(), imageIdentifierFieldMetadataId: z.string().uuid().nullable(),
@ -19,8 +19,8 @@ export const objectMetadataItemSchema = z.object({
isRemote: z.boolean(), isRemote: z.boolean(),
isSystem: z.boolean(), isSystem: z.boolean(),
labelIdentifierFieldMetadataId: z.string().uuid().nullable(), labelIdentifierFieldMetadataId: z.string().uuid().nullable(),
labelPlural: metadataLabelSchema, labelPlural: metadataLabelSchema(),
labelSingular: metadataLabelSchema, labelSingular: metadataLabelSchema(),
namePlural: camelCaseStringSchema, namePlural: camelCaseStringSchema,
nameSingular: camelCaseStringSchema, nameSingular: camelCaseStringSchema,
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),

View File

@ -1,22 +1,27 @@
import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema'; import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { getErrorMessageFromError } from '@/settings/data-model/fields/forms/utils/errorMessages';
import { IconPicker } from '@/ui/input/components/IconPicker'; import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea'; import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
export const settingsDataModelFieldAboutFormSchema = export const settingsDataModelFieldAboutFormSchema = (
fieldMetadataItemSchema.pick({ existingLabels?: string[],
) => {
return fieldMetadataItemSchema(existingLabels || []).pick({
description: true, description: true,
icon: true, icon: true,
label: true, label: true,
}); });
};
// Correctly infer the type from the returned schema
type SettingsDataModelFieldAboutFormValues = z.infer< type SettingsDataModelFieldAboutFormValues = z.infer<
typeof settingsDataModelFieldAboutFormSchema ReturnType<typeof settingsDataModelFieldAboutFormSchema>
>; >;
type SettingsDataModelFieldAboutFormProps = { type SettingsDataModelFieldAboutFormProps = {
@ -32,13 +37,18 @@ const StyledInputsContainer = styled.div`
width: 100%; width: 100%;
`; `;
const LABEL = 'label';
export const SettingsDataModelFieldAboutForm = ({ export const SettingsDataModelFieldAboutForm = ({
disabled, disabled,
fieldMetadataItem, fieldMetadataItem,
maxLength, maxLength,
}: SettingsDataModelFieldAboutFormProps) => { }: SettingsDataModelFieldAboutFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldAboutFormValues>(); const {
control,
trigger,
formState: { errors },
} = useFormContext<SettingsDataModelFieldAboutFormValues>();
return ( return (
<> <>
<StyledInputsContainer> <StyledInputsContainer>
@ -56,14 +66,18 @@ export const SettingsDataModelFieldAboutForm = ({
)} )}
/> />
<Controller <Controller
name="label" name={LABEL}
control={control} control={control}
defaultValue={fieldMetadataItem?.label} defaultValue={fieldMetadataItem?.label}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<TextInput <TextInput
placeholder="Employees" placeholder="Employees"
value={value} value={value}
onChange={onChange} onChange={(e) => {
onChange(e);
trigger(LABEL);
}}
error={getErrorMessageFromError(errors.label?.message)}
disabled={disabled} disabled={disabled}
maxLength={maxLength} maxLength={maxLength}
fullWidth fullWidth

View File

@ -17,7 +17,7 @@ import { TextInput } from '@/ui/input/components/TextInput';
export const settingsDataModelFieldRelationFormSchema = z.object({ export const settingsDataModelFieldRelationFormSchema = z.object({
relation: z.object({ relation: z.object({
field: fieldMetadataItemSchema.pick({ field: fieldMetadataItemSchema().pick({
icon: true, icon: true,
label: true, label: true,
}), }),

View File

@ -0,0 +1,18 @@
export enum errors {
LabelNotUnique = 'LABEL_NOT_UNIQUE',
LabelNotFormattable = 'LABEL_NOT_FORMATTABLE',
LabelEmpty = 'LABEL_EMPTY',
}
export const getErrorMessageFromError = (error?: string) => {
switch (error) {
case errors.LabelEmpty:
return 'Name cannot be empty.';
case errors.LabelNotFormattable:
return 'Name should start with a letter.';
case errors.LabelNotUnique:
return 'This name is already used.';
default:
return '';
}
};

View File

@ -4,8 +4,10 @@ import { settingsDataModelFieldAboutFormSchema } from '@/settings/data-model/fie
import { settingsDataModelFieldSettingsFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard'; import { settingsDataModelFieldSettingsFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { settingsDataModelFieldTypeFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect'; import { settingsDataModelFieldTypeFormSchema } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldTypeSelect';
export const settingsFieldFormSchema = z export const settingsFieldFormSchema = (existingLabels?: string[]) => {
.object({}) return z
.merge(settingsDataModelFieldAboutFormSchema) .object({})
.merge(settingsDataModelFieldTypeFormSchema) .merge(settingsDataModelFieldAboutFormSchema(existingLabels))
.and(settingsDataModelFieldSettingsFormSchema); .merge(settingsDataModelFieldTypeFormSchema)
.and(settingsDataModelFieldSettingsFormSchema);
};

View File

@ -1,16 +1,15 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { import {
ChangeEvent, ChangeEvent,
FocusEventHandler, FocusEventHandler,
ForwardedRef, ForwardedRef,
forwardRef,
InputHTMLAttributes, InputHTMLAttributes,
forwardRef,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { useTheme } from '@emotion/react'; import { IconComponent, IconEye, IconEyeOff } from 'twenty-ui';
import styled from '@emotion/styled';
import { IconAlertCircle, IconComponent, IconEye, IconEyeOff } from 'twenty-ui';
import { useCombinedRefs } from '~/hooks/useCombinedRefs'; import { useCombinedRefs } from '~/hooks/useCombinedRefs';
const StyledContainer = styled.div< const StyledContainer = styled.div<
@ -35,10 +34,12 @@ const StyledInputContainer = styled.div`
`; `;
const StyledInput = styled.input< const StyledInput = styled.input<
Pick<TextInputV2ComponentProps, 'fullWidth' | 'LeftIcon'> Pick<TextInputV2ComponentProps, 'fullWidth' | 'LeftIcon' | 'error'>
>` >`
background-color: ${({ theme }) => theme.background.transparent.lighter}; background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid
${({ theme, error }) =>
error ? theme.border.color.danger : theme.border.color.medium};
border-bottom-left-radius: ${({ theme, LeftIcon }) => border-bottom-left-radius: ${({ theme, LeftIcon }) =>
!LeftIcon && theme.border.radius.sm}; !LeftIcon && theme.border.radius.sm};
border-right: none; border-right: none;
@ -86,10 +87,14 @@ const StyledLeftIconContainer = styled.div`
padding-left: ${({ theme }) => theme.spacing(2)}; padding-left: ${({ theme }) => theme.spacing(2)};
`; `;
const StyledTrailingIconContainer = styled.div` const StyledTrailingIconContainer = styled.div<
Pick<TextInputV2ComponentProps, 'error'>
>`
align-items: center; align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter}; background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid
${({ theme, error }) =>
error ? theme.border.color.danger : theme.border.color.medium};
border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm}; border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
border-left: none; border-left: none;
border-top-right-radius: ${({ theme }) => theme.border.radius.sm}; border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
@ -191,14 +196,10 @@ const TextInputV2Component = (
value, value,
LeftIcon, LeftIcon,
maxLength, maxLength,
error,
}} }}
/> />
<StyledTrailingIconContainer> <StyledTrailingIconContainer {...{ error }}>
{error && (
<StyledTrailingIcon>
<IconAlertCircle size={16} color={theme.color.red} />
</StyledTrailingIcon>
)}
{!error && type === INPUT_TYPE_PASSWORD && ( {!error && type === INPUT_TYPE_PASSWORD && (
<StyledTrailingIcon <StyledTrailingIcon
onClick={handleTogglePasswordVisibility} onClick={handleTogglePasswordVisibility}

View File

@ -38,7 +38,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
type SettingsDataModelFieldEditFormValues = z.infer< type SettingsDataModelFieldEditFormValues = z.infer<
typeof settingsFieldFormSchema ReturnType<typeof settingsFieldFormSchema>
>; >;
const StyledSettingsObjectFieldTypeSelect = styled( const StyledSettingsObjectFieldTypeSelect = styled(
@ -93,7 +93,7 @@ export const SettingsObjectFieldEdit = () => {
const formConfig = useForm<SettingsDataModelFieldEditFormValues>({ const formConfig = useForm<SettingsDataModelFieldEditFormValues>({
mode: 'onTouched', mode: 'onTouched',
resolver: zodResolver(settingsFieldFormSchema), resolver: zodResolver(settingsFieldFormSchema()),
}); });
useEffect(() => { useEffect(() => {

View File

@ -36,7 +36,7 @@ import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type SettingsDataModelNewFieldFormValues = z.infer< type SettingsDataModelNewFieldFormValues = z.infer<
typeof settingsFieldFormSchema ReturnType<typeof settingsFieldFormSchema>
>; >;
const StyledSettingsObjectFieldTypeSelect = styled( const StyledSettingsObjectFieldTypeSelect = styled(
@ -59,7 +59,11 @@ export const SettingsObjectNewFieldStep2 = () => {
const formConfig = useForm<SettingsDataModelNewFieldFormValues>({ const formConfig = useForm<SettingsDataModelNewFieldFormValues>({
mode: 'onTouched', mode: 'onTouched',
resolver: zodResolver(settingsFieldFormSchema), resolver: zodResolver(
settingsFieldFormSchema(
activeObjectMetadataItem?.fields.map((value) => value.name),
),
),
}); });
useEffect(() => { useEffect(() => {
@ -169,8 +173,9 @@ export const SettingsObjectNewFieldStep2 = () => {
return ( return (
<RecordFieldValueSelectorContextProvider> <RecordFieldValueSelectorContextProvider>
{/* eslint-disable-next-line react/jsx-props-no-spreading */} <FormProvider // eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formConfig}> {...formConfig}
>
<SubMenuTopBarContainer Icon={IconSettings} title="Settings"> <SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer> <SettingsPageContainer>
<SettingsHeaderContainer> <SettingsHeaderContainer>