[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  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  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 ) 
This commit is contained in:
@ -0,0 +1,2 @@
|
||||
export const SETTINGS_OBJECT_MODEL_IS_LABEL_SYNCED_WITH_NAME_LABEL_DEFAULT_VALUE =
|
||||
true;
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]]
|
||||
`;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
@ -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
|
||||
>;
|
||||
@ -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();
|
||||
@ -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>
|
||||
|
||||
1
packages/twenty-front/src/types/ReadonlyKeysArray.ts
Normal file
1
packages/twenty-front/src/types/ReadonlyKeysArray.ts
Normal file
@ -0,0 +1 @@
|
||||
export type ReadonlyKeysArray<T> = readonly (keyof T)[];
|
||||
3
packages/twenty-front/src/types/ZodNonEmptyString.ts
Normal file
3
packages/twenty-front/src/types/ZodNonEmptyString.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const zodNonEmptyString = z.string().min(1);
|
||||
Reference in New Issue
Block a user