[REFACTOR] Split in two distinct forms Settings Object Model page (#10653)

# Introduction

This PR contains around ~+300 tests + snapshot additions
Please check both object model creation and edition

Closes https://github.com/twentyhq/core-team-issues/issues/355

Refactored into two agnostic forms the Object Model settings page for
instance `/settings/objects/notes#settings`.

## `SettingsDataModelObjectAboutForm`
Added a new abstraction `SettingsUpdateDataModelObjectAboutForm` to wrap
`SettingsDataModelObjectAboutForm` in an `update` context


![image](https://github.com/user-attachments/assets/137b4f85-d5d8-442f-ad81-27653af99c03)
Schema:
```ts
const requiredFormFields = objectMetadataItemSchema.pick({
  description: true,
  icon: true,
  labelPlural: true,
  labelSingular: true,
});
const optionalFormFields = objectMetadataItemSchema
  .pick({
    nameSingular: true,
    namePlural: true,
    isLabelSyncedWithName: true,
  })
  .partial();
export const settingsDataModelObjectAboutFormSchema =
  requiredFormFields.merge(optionalFormFields);
```
##  `SettingsDataModelObjectSettingsFormCard`
Update on change

![image](https://github.com/user-attachments/assets/179da504-7680-498d-818d-d7f80d77736b)
Schema:
```ts
export const settingsDataModelObjectIdentifiersFormSchema =
  objectMetadataItemSchema.pick({
    labelIdentifierFieldMetadataId: true,
    imageIdentifierFieldMetadataId: true,
  });
```

## Error management and validation schema
Improved the frontend validation form in order to attest that:
- Names are in camelCase
- Names are differents
- Names are not empty string ***SHOULD BE DONE SERVER SIDE TOO*** ( will
in a next PR, atm it literally breaks any workspace )
- Labels are differents
- Labels aren't empty strings

Hide the error messages as we need to decide what kind of styling we
want for our errors with forms
( Example with error labels )

![image](https://github.com/user-attachments/assets/d54534f8-8163-42d9-acdc-976a5e723498)
This commit is contained in:
Paul Rastoin
2025-03-07 10:14:25 +01:00
committed by GitHub
parent 21c7d2081d
commit 776632fe79
17 changed files with 646 additions and 382 deletions

View File

@ -0,0 +1,2 @@
export const SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE =
true;

View File

@ -11,8 +11,8 @@ import {
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { t } from '@lingui/core/macro';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type SettingsObjectFieldInactiveActionDropdownProps = {
isCustomField?: boolean;

View File

@ -0,0 +1,106 @@
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE } from '@/settings/constants/SettingsObjectModel';
import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
import {
SettingsDataModelObjectAboutFormValues,
settingsDataModelObjectAboutFormSchema,
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { ZodError } from 'zod';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState';
type SettingsUpdateDataModelObjectAboutFormProps = {
objectMetadataItem: ObjectMetadataItem;
};
export const SettingsUpdateDataModelObjectAboutForm = ({
objectMetadataItem,
}: SettingsUpdateDataModelObjectAboutFormProps) => {
const navigate = useNavigateSettings();
const { enqueueSnackBar } = useSnackBar();
const setUpdatedObjectNamePlural = useSetRecoilState(
updatedObjectNamePluralState,
);
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const {
description,
icon,
isLabelSyncedWithName,
labelPlural,
labelSingular,
namePlural,
nameSingular,
} = objectMetadataItem;
const formConfig = useForm<SettingsDataModelObjectAboutFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsDataModelObjectAboutFormSchema),
defaultValues: {
description,
icon: icon ?? undefined,
isLabelSyncedWithName: isDefined(isLabelSyncedWithName)
? isLabelSyncedWithName
: SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE,
labelPlural,
labelSingular,
namePlural,
nameSingular,
},
});
const handleSave = async (
formValues: SettingsDataModelObjectAboutFormValues,
) => {
if (!(Object.keys(formConfig.formState.dirtyFields).length > 0)) {
return;
}
const objectNamePluralForRedirection =
formValues.namePlural ?? objectMetadataItem.namePlural;
try {
setUpdatedObjectNamePlural(objectNamePluralForRedirection);
await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
updatePayload: formValues,
});
formConfig.reset(undefined, { keepValues: true });
navigate(SettingsPath.ObjectDetail, {
objectNamePlural: objectNamePluralForRedirection,
});
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
if (error instanceof ZodError) {
enqueueSnackBar(error.issues[0].message, {
variant: SnackBarVariant.Error,
});
} else {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
}
};
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<FormProvider {...formConfig}>
<SettingsDataModelObjectAboutForm
onNewDirtyField={() => formConfig.handleSubmit(handleSave)()}
disableEdition={!objectMetadataItem.isCustom}
objectMetadataItem={objectMetadataItem}
/>
</FormProvider>
);
};

View File

@ -1,39 +1,14 @@
/* eslint-disable react/jsx-props-no-spreading */
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { Button, H2Title, IconArchive, Section } from 'twenty-ui';
import { ZodError, z } from 'zod';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import {
IS_LABEL_SYNCED_WITH_NAME_LABEL,
SettingsDataModelObjectAboutForm,
settingsDataModelObjectAboutFormSchema,
} from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
import { settingsDataModelObjectIdentifiersFormSchema } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm';
import { SettingsUpdateDataModelObjectAboutForm } from '@/settings/data-model/object-details/components/SettingsUpdateDataModelObjectAboutForm';
import { SettingsDataModelObjectSettingsFormCard } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectSettingsFormCard';
import { settingsUpdateObjectInputSchema } from '@/settings/data-model/validation-schemas/settingsUpdateObjectInputSchema';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import pick from 'lodash.pick';
import { useSetRecoilState } from 'recoil';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { updatedObjectNamePluralState } from '~/pages/settings/data-model/states/updatedObjectNamePluralState';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
const objectEditFormSchema = z
.object({})
.merge(settingsDataModelObjectAboutFormSchema)
.merge(settingsDataModelObjectIdentifiersFormSchema);
type SettingsDataModelObjectEditFormValues = z.infer<
typeof objectEditFormSchema
>;
type ObjectSettingsProps = {
objectMetadataItem: ObjectMetadataItem;
@ -52,101 +27,7 @@ const StyledFormSection = styled(Section)`
export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSnackBar } = useSnackBar();
const setUpdatedObjectNamePlural = useSetRecoilState(
updatedObjectNamePluralState,
);
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const formConfig = useForm<SettingsDataModelObjectEditFormValues>({
mode: 'onTouched',
resolver: zodResolver(objectEditFormSchema),
});
const { isDirty } = formConfig.formState;
const getUpdatePayload = (
formValues: SettingsDataModelObjectEditFormValues,
) => {
let values = formValues;
const dirtyFieldKeys = Object.keys(
formConfig.formState.dirtyFields,
) as (keyof SettingsDataModelObjectEditFormValues)[];
const shouldComputeNamesFromLabels: boolean = dirtyFieldKeys.includes(
IS_LABEL_SYNCED_WITH_NAME_LABEL,
)
? (formValues.isLabelSyncedWithName as boolean)
: objectMetadataItem.isLabelSyncedWithName;
if (shouldComputeNamesFromLabels) {
values = {
...values,
...(values.labelSingular && dirtyFieldKeys.includes('labelSingular')
? {
nameSingular: computeMetadataNameFromLabel(
formValues.labelSingular,
),
}
: {}),
...(values.labelPlural && dirtyFieldKeys.includes('labelPlural')
? {
namePlural: computeMetadataNameFromLabel(formValues.labelPlural),
}
: {}),
};
}
return settingsUpdateObjectInputSchema.parse(
pick(values, [
...dirtyFieldKeys,
...(shouldComputeNamesFromLabels &&
dirtyFieldKeys.includes('labelPlural')
? ['namePlural']
: []),
...(shouldComputeNamesFromLabels &&
dirtyFieldKeys.includes('labelSingular')
? ['nameSingular']
: []),
]),
);
};
const handleSave = async (
formValues: SettingsDataModelObjectEditFormValues,
) => {
if (!isDirty) {
return;
}
try {
const updatePayload = getUpdatePayload(formValues);
const objectNamePluralForRedirection =
updatePayload.namePlural ?? objectMetadataItem.namePlural;
setUpdatedObjectNamePlural(objectNamePluralForRedirection);
await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
updatePayload,
});
formConfig.reset(undefined, { keepValues: true });
navigate(SettingsPath.ObjectDetail, {
objectNamePlural: objectNamePluralForRedirection,
});
} catch (error) {
if (error instanceof ZodError) {
enqueueSnackBar(error.issues[0].message, {
variant: SnackBarVariant.Error,
});
} else {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
}
};
const handleDisable = async () => {
await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
@ -157,49 +38,42 @@ export const ObjectSettings = ({ objectMetadataItem }: ObjectSettingsProps) => {
return (
<RecordFieldValueSelectorContextProvider>
<FormProvider {...formConfig}>
<StyledContentContainer>
<StyledFormSection>
<StyledContentContainer>
<StyledFormSection>
<H2Title
title={t`About`}
description={t`Name in both singular (e.g., 'Invoice') and plural (e.g., 'Invoices') forms.`}
/>
<SettingsUpdateDataModelObjectAboutForm
objectMetadataItem={objectMetadataItem}
/>
</StyledFormSection>
<StyledFormSection>
<Section>
<H2Title
title={t`About`}
description={t`Name in both singular (e.g., 'Invoice') and plural (e.g., 'Invoices') forms.`}
title={t`Options`}
description={t`Choose the fields that will identify your records`}
/>
<SettingsDataModelObjectAboutForm
disableEdition={!objectMetadataItem.isCustom}
<SettingsDataModelObjectSettingsFormCard
objectMetadataItem={objectMetadataItem}
onBlur={() => {
formConfig.handleSubmit(handleSave)();
}}
/>
</StyledFormSection>
<StyledFormSection>
<Section>
<H2Title
title={t`Options`}
description={t`Choose the fields that will identify your records`}
/>
<SettingsDataModelObjectSettingsFormCard
onBlur={() => formConfig.handleSubmit(handleSave)()}
objectMetadataItem={objectMetadataItem}
/>
</Section>
</StyledFormSection>
<StyledFormSection>
<Section>
<H2Title
title={t`Danger zone`}
description={t`Deactivate object`}
/>
<Button
Icon={IconArchive}
title={t`Deactivate`}
size="small"
onClick={handleDisable}
/>
</Section>
</StyledFormSection>
</StyledContentContainer>
</FormProvider>
</Section>
</StyledFormSection>
<StyledFormSection>
<Section>
<H2Title
title={t`Danger zone`}
description={t`Deactivate object`}
/>
<Button
Icon={IconArchive}
title={t`Deactivate`}
size="small"
onClick={handleDisable}
/>
</Section>
</StyledFormSection>
</StyledContentContainer>
</RecordFieldValueSelectorContextProvider>
);
};

View File

@ -1,8 +1,9 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE } from '@/settings/constants/SettingsObjectModel';
import { OBJECT_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/ObjectNameMaximumLength';
import { SettingsDataModelObjectAboutFormValues } from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
@ -19,34 +20,13 @@ import {
IconRefresh,
TooltipDelay,
} from 'twenty-ui';
import { z } from 'zod';
import { StringKeyOf } from 'type-fest';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
export const settingsDataModelObjectAboutFormSchema = objectMetadataItemSchema
.pick({
description: true,
icon: true,
labelPlural: true,
labelSingular: true,
})
.merge(
objectMetadataItemSchema
.pick({
nameSingular: true,
namePlural: true,
isLabelSyncedWithName: true,
})
.partial(),
);
type SettingsDataModelObjectAboutFormValues = z.infer<
typeof settingsDataModelObjectAboutFormSchema
>;
type SettingsDataModelObjectAboutFormProps = {
disableEdition?: boolean;
objectMetadataItem?: ObjectMetadataItem;
onBlur?: () => void;
onNewDirtyField?: () => void;
};
const StyledInputsContainer = styled.div`
@ -92,21 +72,20 @@ const infoCircleElementId = 'info-circle-id';
export const IS_LABEL_SYNCED_WITH_NAME_LABEL = 'isLabelSyncedWithName';
export const SettingsDataModelObjectAboutForm = ({
disableEdition,
disableEdition = false,
onNewDirtyField,
objectMetadataItem,
onBlur,
}: SettingsDataModelObjectAboutFormProps) => {
const { t } = useLingui();
const { control, watch, setValue } =
useFormContext<SettingsDataModelObjectAboutFormValues>();
const { t } = useLingui();
const theme = useTheme();
const isLabelSyncedWithName =
watch(IS_LABEL_SYNCED_WITH_NAME_LABEL) ??
(isDefined(objectMetadataItem)
? objectMetadataItem.isLabelSyncedWithName
: true);
: SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE);
const labelSingular = watch('labelSingular');
const labelPlural = watch('labelPlural');
watch('nameSingular');
@ -117,30 +96,34 @@ export const SettingsDataModelObjectAboutForm = ({
? t`Deactivate "Synchronize Objects Labels and API Names" to set a custom API name`
: t`Input must be in camel case and cannot start with a number`;
const fillLabelPlural = (labelSingular: string) => {
const newLabelPluralValue = isDefined(labelSingular)
? plural(labelSingular)
: '';
setValue('labelPlural', newLabelPluralValue, {
shouldDirty: isDefined(labelSingular) ? true : false,
const fillLabelPlural = (labelSingular: string | undefined) => {
if (!isDefined(labelSingular)) return;
const labelPluralFromSingularLabel = plural(labelSingular);
setValue('labelPlural', labelPluralFromSingularLabel, {
shouldDirty: true,
});
if (isLabelSyncedWithName === true) {
fillNamePluralFromLabelPlural(newLabelPluralValue);
fillNamePluralFromLabelPlural(labelPluralFromSingularLabel);
}
};
const fillNameSingularFromLabelSingular = (labelSingular: string) => {
isDefined(labelSingular) &&
setValue('nameSingular', computeMetadataNameFromLabel(labelSingular), {
shouldDirty: true,
});
const fillNameSingularFromLabelSingular = (
labelSingular: string | undefined,
) => {
if (!isDefined(labelSingular)) return;
setValue('nameSingular', computeMetadataNameFromLabel(labelSingular), {
shouldDirty: true,
});
};
const fillNamePluralFromLabelPlural = (labelPlural: string) => {
isDefined(labelPlural) &&
setValue('namePlural', computeMetadataNameFromLabel(labelPlural), {
shouldDirty: true,
});
const fillNamePluralFromLabelPlural = (labelPlural: string | undefined) => {
if (!isDefined(labelPlural)) return;
setValue('namePlural', computeMetadataNameFromLabel(labelPlural), {
shouldDirty: true,
});
};
return (
@ -158,7 +141,7 @@ export const SettingsDataModelObjectAboutForm = ({
selectedIconKey={value}
onChange={({ iconKey }) => {
onChange(iconKey);
onBlur?.();
onNewDirtyField?.();
}}
/>
)}
@ -168,9 +151,12 @@ export const SettingsDataModelObjectAboutForm = ({
key={`object-labelSingular-text-input`}
name={'labelSingular'}
control={control}
defaultValue={objectMetadataItem?.labelSingular}
render={({ field: { onChange, value } }) => (
defaultValue={objectMetadataItem?.labelSingular ?? ''}
render={({ field: { onChange, value }, formState: { errors } }) => (
<TextInput
// TODO we should discuss on how to notify user about form validation schema issue, from now just displaying red borders
noErrorHelper={true}
error={errors.labelSingular?.message}
label={t`Singular`}
placeholder={'Listing'}
value={value}
@ -181,7 +167,7 @@ export const SettingsDataModelObjectAboutForm = ({
fillNameSingularFromLabelSingular(value);
}
}}
onBlur={onBlur}
onBlur={() => onNewDirtyField?.()}
disabled={disableEdition}
fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
@ -192,9 +178,12 @@ export const SettingsDataModelObjectAboutForm = ({
key={`object-labelPlural-text-input`}
name={'labelPlural'}
control={control}
defaultValue={objectMetadataItem?.labelPlural}
render={({ field: { onChange, value } }) => (
defaultValue={objectMetadataItem?.labelPlural ?? ''}
render={({ field: { onChange, value }, formState: { errors } }) => (
<TextInput
// TODO we should discuss on how to notify user about form validation schema issue, from now just displaying red borders
noErrorHelper={true}
error={errors.labelPlural?.message}
label={t`Plural`}
placeholder={t`Listings`}
value={value}
@ -204,6 +193,7 @@ export const SettingsDataModelObjectAboutForm = ({
fillNamePluralFromLabelPlural(value);
}
}}
onBlur={() => onNewDirtyField?.()}
disabled={disableEdition}
fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
@ -214,7 +204,6 @@ export const SettingsDataModelObjectAboutForm = ({
<Controller
name="description"
control={control}
defaultValue={objectMetadataItem?.description ?? null}
render={({ field: { onChange, value } }) => (
<TextArea
placeholder={t`Write a description`}
@ -222,7 +211,7 @@ export const SettingsDataModelObjectAboutForm = ({
value={value ?? undefined}
onChange={(nextValue) => onChange(nextValue ?? null)}
disabled={disableEdition}
onBlur={onBlur}
onBlur={() => onNewDirtyField?.()}
/>
)}
/>
@ -232,28 +221,30 @@ export const SettingsDataModelObjectAboutForm = ({
{[
{
label: t`API Name (Singular)`,
fieldName: 'nameSingular' as const,
fieldName:
'nameSingular' as const satisfies StringKeyOf<ObjectMetadataItem>,
placeholder: `listing`,
defaultValue: objectMetadataItem?.nameSingular,
defaultValue: objectMetadataItem?.nameSingular ?? '',
disableEdition: disableEdition || isLabelSyncedWithName,
tooltip: apiNameTooltipText,
},
{
label: t`API Name (Plural)`,
fieldName: 'namePlural' as const,
fieldName:
'namePlural' as const satisfies StringKeyOf<ObjectMetadataItem>,
placeholder: `listings`,
defaultValue: objectMetadataItem?.namePlural,
defaultValue: objectMetadataItem?.namePlural ?? '',
disableEdition: disableEdition || isLabelSyncedWithName,
tooltip: apiNameTooltipText,
},
].map(
({
defaultValue,
fieldName,
label,
placeholder,
disableEdition,
tooltip,
defaultValue,
}) => (
<AdvancedSettingsWrapper key={`object-${fieldName}-text-input`}>
<StyledInputContainer>
@ -261,7 +252,10 @@ export const SettingsDataModelObjectAboutForm = ({
name={fieldName}
control={control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => (
render={({
field: { onChange, value },
formState: { errors },
}) => (
<>
<TextInput
label={label}
@ -271,7 +265,10 @@ export const SettingsDataModelObjectAboutForm = ({
disabled={disableEdition}
fullWidth
maxLength={OBJECT_NAME_MAXIMUM_LENGTH}
onBlur={onBlur}
onBlur={() => onNewDirtyField?.()}
error={errors[fieldName]?.message}
// TODO we should discuss on how to notify user about form validation schema issue, from now just displaying red borders
noErrorHelper={true}
RightIcon={() =>
tooltip && (
<>
@ -305,7 +302,10 @@ export const SettingsDataModelObjectAboutForm = ({
<Controller
name={IS_LABEL_SYNCED_WITH_NAME_LABEL}
control={control}
defaultValue={objectMetadataItem?.isLabelSyncedWithName ?? true}
defaultValue={
objectMetadataItem?.isLabelSyncedWithName ??
SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE
}
render={({ field: { onChange, value } }) => (
<Card rounded>
<SettingsOptionCardContentToggle
@ -324,7 +324,7 @@ export const SettingsDataModelObjectAboutForm = ({
fillNamePluralFromLabelPlural(labelPlural);
fillNameSingularFromLabelSingular(labelSingular);
}
onBlur?.();
onNewDirtyField?.();
}}
/>
</Card>

View File

@ -1,14 +1,18 @@
import styled from '@emotion/styled';
import { useMemo } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { IconCircleOff, useIcons } from 'twenty-ui';
import { z } from 'zod';
import { ZodError, isDirty, z } from 'zod';
import { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from '@/object-metadata/constants/LabelIdentifierFieldMetadataTypes';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getActiveFieldMetadataItems } from '@/object-metadata/utils/getActiveFieldMetadataItems';
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { zodResolver } from '@hookform/resolvers/zod';
import { t } from '@lingui/core/macro';
export const settingsDataModelObjectIdentifiersFormSchema =
@ -24,7 +28,6 @@ export type SettingsDataModelObjectIdentifiers =
keyof SettingsDataModelObjectIdentifiersFormValues;
type SettingsDataModelObjectIdentifiersFormProps = {
objectMetadataItem: ObjectMetadataItem;
onBlur: () => void;
};
const LABEL_IDENTIFIER_FIELD_METADATA_ID: SettingsDataModelObjectIdentifiers =
'labelIdentifierFieldMetadataId';
@ -38,10 +41,41 @@ const StyledContainer = styled.div`
export const SettingsDataModelObjectIdentifiersForm = ({
objectMetadataItem,
onBlur,
}: SettingsDataModelObjectIdentifiersFormProps) => {
const { control } =
useFormContext<SettingsDataModelObjectIdentifiersFormValues>();
const formConfig = useForm<SettingsDataModelObjectIdentifiersFormValues>({
mode: 'onTouched',
resolver: zodResolver(settingsDataModelObjectIdentifiersFormSchema),
});
const { enqueueSnackBar } = useSnackBar();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const handleSave = async (
formValues: SettingsDataModelObjectIdentifiersFormValues,
) => {
if (!isDirty) {
return;
}
try {
await updateOneObjectMetadataItem({
idToUpdate: objectMetadataItem.id,
updatePayload: formValues,
});
formConfig.reset(undefined, { keepValues: true });
} catch (error) {
if (error instanceof ZodError) {
enqueueSnackBar(error.issues[0].message, {
variant: SnackBarVariant.Error,
});
} else {
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
}
}
};
const { getIcon } = useIcons();
const labelIdentifierFieldOptions = useMemo(
() =>
@ -84,7 +118,7 @@ export const SettingsDataModelObjectIdentifiersForm = ({
<Controller
key={fieldName}
name={fieldName}
control={control}
control={formConfig.control}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => (
<Select
@ -97,7 +131,7 @@ export const SettingsDataModelObjectIdentifiersForm = ({
value={value}
onChange={(value) => {
onChange(value);
onBlur();
formConfig.handleSubmit(handleSave)();
}}
/>
)}

View File

@ -12,7 +12,6 @@ import { Card, CardContent } from 'twenty-ui';
type SettingsDataModelObjectSettingsFormCardProps = {
objectMetadataItem: ObjectMetadataItem;
onBlur: () => void;
};
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
@ -35,7 +34,6 @@ const StyledObjectSummaryCardContent = styled(CardContent)`
export const SettingsDataModelObjectSettingsFormCard = ({
objectMetadataItem,
onBlur,
}: SettingsDataModelObjectSettingsFormCardProps) => {
const labelIdentifierFieldMetadataItem = useMemo(() => {
return getLabelIdentifierFieldMetadataItem({
@ -70,7 +68,6 @@ export const SettingsDataModelObjectSettingsFormCard = ({
<CardContent>
<SettingsDataModelObjectIdentifiersForm
objectMetadataItem={objectMetadataItem}
onBlur={onBlur}
/>
</CardContent>
</Card>

View File

@ -0,0 +1,159 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`settingsDataModelObjectAboutFormSchema fails when isLabelSyncedWithName is not a boolean 1`] = `
[ZodError: [
{
"code": "invalid_type",
"expected": "boolean",
"received": "string",
"path": [
"isLabelSyncedWithName"
],
"message": "Expected boolean, received string"
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when labels are empty strings 1`] = `
[ZodError: [
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"exact": false,
"message": "String must contain at least 1 character(s)",
"path": [
"labelSingular"
]
},
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"exact": false,
"message": "String must contain at least 1 character(s)",
"path": [
"labelPlural"
]
},
{
"code": "custom",
"message": "Singular and plural labels must be different",
"path": [
"labelPlural"
]
},
{
"code": "custom",
"message": "Singular and plural labels must be different",
"path": [
"labelSingular"
]
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when names are not in camelCase 1`] = `
[ZodError: [
{
"code": "custom",
"message": "String should be camel case",
"path": [
"namePlural"
]
},
{
"code": "custom",
"message": "String should be camel case",
"path": [
"nameSingular"
]
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when required fields are missing 1`] = `
[ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": [
"labelSingular"
],
"message": "Required"
},
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": [
"labelPlural"
],
"message": "Required"
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when singular and plural labels are the same 1`] = `
[ZodError: [
{
"code": "custom",
"message": "Singular and plural labels must be different",
"path": [
"labelPlural"
]
},
{
"code": "custom",
"message": "Singular and plural labels must be different",
"path": [
"labelSingular"
]
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails when singular and plural names are the same 1`] = `
[ZodError: [
{
"code": "custom",
"message": "Singular and plural names must be different",
"path": [
"nameSingular"
]
},
{
"code": "custom",
"message": "Singular and plural names must be different",
"path": [
"namePlural"
]
}
]]
`;
exports[`settingsDataModelObjectAboutFormSchema fails with invalid types for optional fields 1`] = `
[ZodError: [
{
"code": "invalid_type",
"expected": "string",
"received": "number",
"path": [
"description"
],
"message": "Expected string, received number"
},
{
"code": "invalid_type",
"expected": "string",
"received": "boolean",
"path": [
"icon"
],
"message": "Expected string, received boolean"
}
]]
`;

View File

@ -1,51 +0,0 @@
import { SafeParseSuccess } from 'zod';
import { CreateObjectInput } from '~/generated-metadata/graphql';
import { settingsCreateObjectInputSchema } from '../settingsCreateObjectInputSchema';
describe('settingsCreateObjectInputSchema', () => {
it('validates a valid input and adds name properties', () => {
// Given
const validInput = {
description: 'A valid description',
icon: 'IconPlus',
labelPlural: ' Labels ',
labelSingular: 'Label ',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
isLabelSyncedWithName: false,
};
// When
const result = settingsCreateObjectInputSchema.safeParse(validInput);
// Then
expect(result.success).toBe(true);
expect((result as SafeParseSuccess<CreateObjectInput>).data).toEqual({
description: validInput.description,
icon: validInput.icon,
labelPlural: 'Labels',
labelSingular: 'Label',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
isLabelSyncedWithName: false,
});
});
it('fails for an invalid input', () => {
// Given
const invalidInput = {
description: 123,
icon: true,
labelPlural: [],
labelSingular: {},
};
// When
const result = settingsCreateObjectInputSchema.safeParse(invalidInput);
// Then
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,150 @@
import {
SettingsDataModelObjectAboutFormValues,
settingsDataModelObjectAboutFormSchema,
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { EachTestingContext } from '~/types/EachTestingContext';
describe('settingsDataModelObjectAboutFormSchema', () => {
const validInput: SettingsDataModelObjectAboutFormValues = {
description: 'A valid description',
icon: 'IconName',
labelPlural: 'Labels Plural',
labelSingular: 'Label Singular',
namePlural: 'labelsPlural',
nameSingular: 'labelSingular',
isLabelSyncedWithName: false,
};
const passingTestsUseCase: EachTestingContext<{
input: SettingsDataModelObjectAboutFormValues;
expectedSuccess: true;
}>[] = [
{
title: 'validates a complete valid input',
context: {
input: validInput,
expectedSuccess: true,
},
},
{
title: 'validates input with optional fields omitted',
context: {
input: {
labelPlural: 'Labels Plural',
labelSingular: 'Label Singular',
namePlural: 'labelsPlural',
nameSingular: 'labelSingular',
isLabelSyncedWithName: false,
},
expectedSuccess: true,
},
},
{
title: 'validates input with trimmed labels',
context: {
input: {
...validInput,
labelPlural: ' Labels Plural ',
labelSingular: ' Label Singular ',
},
expectedSuccess: true,
},
},
];
const failsValidationTestsUseCase: EachTestingContext<{
input: Partial<Record<keyof SettingsDataModelObjectAboutFormValues, any>>;
expectedSuccess: false;
}>[] = [
{
title: 'fails when required fields are missing',
context: {
input: {
description: 'Only description',
labelPlural: undefined,
labelSingular: undefined,
},
expectedSuccess: false,
},
},
{
title: 'fails when names are not in camelCase',
context: {
input: {
...validInput,
namePlural: 'Labels_Plural',
nameSingular: 'Label-Singular',
},
expectedSuccess: false,
},
},
{
title: 'fails when labels are empty strings',
context: {
input: {
...validInput,
labelPlural: '',
labelSingular: '',
},
expectedSuccess: false,
},
},
{
title: 'fails when singular and plural labels are the same',
context: {
input: {
...validInput,
labelPlural: 'Same Label',
labelSingular: 'Same Label',
},
expectedSuccess: false,
},
},
{
title: 'fails when singular and plural names are the same',
context: {
input: {
...validInput,
namePlural: 'sameName',
nameSingular: 'sameName',
},
expectedSuccess: false,
},
},
{
title: 'fails when isLabelSyncedWithName is not a boolean',
context: {
input: {
...validInput,
isLabelSyncedWithName: 'true',
},
expectedSuccess: false,
},
},
{
title: 'fails with invalid types for optional fields',
context: {
input: {
...validInput,
description: 123,
icon: true,
},
expectedSuccess: false,
},
},
];
test.each([...passingTestsUseCase, ...failsValidationTestsUseCase])(
'$title',
({ context: { expectedSuccess, input } }) => {
const result = settingsDataModelObjectAboutFormSchema.safeParse({
...validInput,
...input,
});
expect(result.success).toBe(expectedSuccess);
if (!expectedSuccess) {
expect(result.error).toMatchSnapshot();
}
},
);
});

View File

@ -1,52 +0,0 @@
import { SafeParseSuccess } from 'zod';
import { UpdateObjectPayload } from '~/generated-metadata/graphql';
import { settingsUpdateObjectInputSchema } from '../settingsUpdateObjectInputSchema';
describe('settingsUpdateObjectInputSchema', () => {
it('validates a valid input and adds name properties', () => {
// Given
const validInput = {
description: 'A valid description',
icon: 'IconName',
labelPlural: 'Labels Plural ',
labelSingular: ' Label Singular',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
labelIdentifierFieldMetadataId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
};
// When
const result = settingsUpdateObjectInputSchema.safeParse(validInput);
// Then
expect(result.success).toBe(true);
expect((result as SafeParseSuccess<UpdateObjectPayload>).data).toEqual({
description: validInput.description,
icon: validInput.icon,
labelIdentifierFieldMetadataId: validInput.labelIdentifierFieldMetadataId,
labelPlural: 'Labels Plural',
labelSingular: 'Label Singular',
namePlural: 'namePlural',
nameSingular: 'nameSingular',
});
});
it('fails for an invalid input', () => {
// Given
const invalidInput = {
description: 123,
icon: true,
labelPlural: [],
labelSingular: {},
labelIdentifierFieldMetadataId: 'invalid uuid',
};
// When
const result = settingsUpdateObjectInputSchema.safeParse(invalidInput);
// Then
expect(result.success).toBe(false);
});
});

View File

@ -1,16 +0,0 @@
import { settingsDataModelObjectAboutFormSchema } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
import { CreateObjectInput } from '~/generated-metadata/graphql';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
export const settingsCreateObjectInputSchema =
settingsDataModelObjectAboutFormSchema.transform<CreateObjectInput>(
(values) => ({
...values,
nameSingular:
values.nameSingular ??
computeMetadataNameFromLabel(values.labelSingular),
namePlural:
values.namePlural ?? computeMetadataNameFromLabel(values.labelPlural),
isLabelSyncedWithName: values.isLabelSyncedWithName ?? true,
}),
);

View File

@ -0,0 +1,68 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { t } from '@lingui/core/macro';
import { ZodType, z } from 'zod';
import { ReadonlyKeysArray } from '~/types/ReadonlyKeysArray';
import { zodNonEmptyString } from '~/types/ZodNonEmptyString';
import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema';
type ZodTypeSettingsDataModelFormFields = ZodType<
Pick<
ObjectMetadataItem,
| 'labelSingular'
| 'labelPlural'
| 'description'
| 'icon'
| 'namePlural'
| 'nameSingular'
| 'isLabelSyncedWithName'
>
>;
const settingsDataModelFormFieldsSchema = z.object({
description: z.string().nullish(),
icon: z.string().optional(),
labelSingular: zodNonEmptyString,
labelPlural: zodNonEmptyString,
namePlural: zodNonEmptyString.and(camelCaseStringSchema),
nameSingular: zodNonEmptyString.and(camelCaseStringSchema),
isLabelSyncedWithName: z.boolean(),
}) satisfies ZodTypeSettingsDataModelFormFields;
export const settingsDataModelObjectAboutFormSchema =
settingsDataModelFormFieldsSchema.superRefine(
({ labelPlural, labelSingular, namePlural, nameSingular }, ctx) => {
const labelsAreDifferent =
labelPlural.trim().toLowerCase() !== labelSingular.trim().toLowerCase();
if (!labelsAreDifferent) {
const labelFields: ReadonlyKeysArray<ObjectMetadataItem> = [
'labelPlural',
'labelSingular',
];
labelFields.forEach((field) =>
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t`Singular and plural labels must be different`,
path: [field],
}),
);
}
const nameAreDifferent =
nameSingular.toLowerCase() !== namePlural.toLowerCase();
if (!nameAreDifferent) {
const nameFields: ReadonlyKeysArray<ObjectMetadataItem> = [
'nameSingular',
'namePlural',
];
nameFields.forEach((field) =>
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t`Singular and plural names must be different`,
path: [field],
}),
);
}
},
);
export type SettingsDataModelObjectAboutFormValues = z.infer<
typeof settingsDataModelObjectAboutFormSchema
>;

View File

@ -1,13 +0,0 @@
import { objectMetadataItemSchema } from '@/object-metadata/validation-schemas/objectMetadataItemSchema';
import { settingsDataModelObjectAboutFormSchema } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
export const settingsUpdateObjectInputSchema =
settingsDataModelObjectAboutFormSchema
.merge(
objectMetadataItemSchema.pick({
imageIdentifierFieldMetadataId: true,
isActive: true,
labelIdentifierFieldMetadataId: true,
}),
)
.partial();

View File

@ -1,16 +1,16 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import { H2Title, Section } from 'twenty-ui';
import { z } from 'zod';
import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreateOneObjectMetadataItem';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE } from '@/settings/constants/SettingsObjectModel';
import { SettingsDataModelObjectAboutForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
import {
SettingsDataModelObjectAboutForm,
SettingsDataModelObjectAboutFormValues,
settingsDataModelObjectAboutFormSchema,
} from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm';
import { settingsCreateObjectInputSchema } from '@/settings/data-model/validation-schemas/settingsCreateObjectInputSchema';
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -19,10 +19,6 @@ import { useLingui } from '@lingui/react/macro';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const newObjectFormSchema = settingsDataModelObjectAboutFormSchema;
type SettingsDataModelNewObjectFormValues = z.infer<typeof newObjectFormSchema>;
export const SettingsNewObject = () => {
const { t } = useLingui();
const navigate = useNavigateSettings();
@ -30,21 +26,23 @@ export const SettingsNewObject = () => {
const { createOneObjectMetadataItem } = useCreateOneObjectMetadataItem();
const formConfig = useForm<SettingsDataModelNewObjectFormValues>({
mode: 'onTouched',
resolver: zodResolver(newObjectFormSchema),
const formConfig = useForm<SettingsDataModelObjectAboutFormValues>({
mode: 'onSubmit',
resolver: zodResolver(settingsDataModelObjectAboutFormSchema),
defaultValues: {
isLabelSyncedWithName:
SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE,
},
});
const { isValid, isSubmitting } = formConfig.formState;
const canSave = isValid && !isSubmitting;
const handleSave = async (
formValues: SettingsDataModelNewObjectFormValues,
formValues: SettingsDataModelObjectAboutFormValues,
) => {
try {
const { data: response } = await createOneObjectMetadataItem(
settingsCreateObjectInputSchema.parse(formValues),
);
const { data: response } = await createOneObjectMetadataItem(formValues);
navigate(
response ? SettingsPath.ObjectDetail : SettingsPath.Objects,
@ -53,6 +51,8 @@ export const SettingsNewObject = () => {
: undefined,
);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error,
});
@ -90,7 +90,9 @@ export const SettingsNewObject = () => {
title={t`About`}
description={t`Define the name and description of your object`}
/>
<SettingsDataModelObjectAboutForm />
<SettingsDataModelObjectAboutForm
onNewDirtyField={() => formConfig.trigger()}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>

View File

@ -0,0 +1 @@
export type ReadonlyKeysArray<T> = readonly (keyof T)[];

View File

@ -0,0 +1,3 @@
import { z } from 'zod';
export const zodNonEmptyString = z.string().min(1);