Morph-settings-relation-and-morph-merge (#15062)

🎯 Merge Settings Relation and Morph Relation Forms

In the settings, we unify morph and relation into a single form. 

The form now automatically creates 
- a `RELATION` field when 1 destination object is selected 
- or a `MORPH_RELATION` field when 2+ objects are selected. 

Better UI labels (showing object name for single selection, "X Objects"
for multiple), proper field editing controls on destination objects, and
capitalized field labels.

We still make sure the isMorphRelationEnabled feature flag prevents
current users from accessing this feature

Fixes https://github.com/twentyhq/core-team-issues/issues/1589
This commit is contained in:
Guillim
2025-10-16 09:56:24 +02:00
committed by GitHub
parent d3f3f991a5
commit 20fdb66bd3
16 changed files with 241 additions and 520 deletions
@@ -15,15 +15,11 @@ import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/
import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
import { SettingsDataModelFieldDateSettingsFormCard } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard';
import { settingsDataModelFieldMorphRelationFormSchema } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldMorphRelationForm';
import { SettingsDataModelFieldMorphRelationFormCard } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldMorphRelationFormCard';
import { settingsDataModelFieldMorphRelationFormSchema } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm';
import { settingsDataModelFieldNumberFormSchema } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
import { SettingsDataModelFieldNumberSettingsFormCard } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard';
import { settingsDataModelFieldPhonesFormSchema } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
import { SettingsDataModelFieldPhonesSettingsFormCard } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard';
import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm';
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard';
import {
settingsDataModelFieldMultiSelectFormSchema,
settingsDataModelFieldSelectFormSchema,
@@ -31,7 +27,9 @@ import {
import { SettingsDataModelFieldSelectSettingsFormCard } from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectSettingsFormCard';
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { Separator } from '@/settings/components/Separator';
import { SettingsDataModelFieldRelationFormCard } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationFormCard';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
@@ -58,12 +56,10 @@ const dateTimeFieldFormSchema = z
.extend(settingsDataModelFieldDateFormSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const relationFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.RELATION) })
.extend(settingsDataModelFieldRelationFormSchema.shape);
const morphRelationFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.MORPH_RELATION) })
.object({
type: z.literal(FieldMetadataType.MORPH_RELATION),
})
.extend(settingsDataModelFieldMorphRelationFormSchema.shape);
const selectFieldFormSchema = z
@@ -115,7 +111,6 @@ const otherFieldsFormSchema = z
omit(SETTINGS_FIELD_TYPE_CONFIGS, [
FieldMetadataType.BOOLEAN,
FieldMetadataType.CURRENCY,
FieldMetadataType.RELATION,
FieldMetadataType.MORPH_RELATION,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
@@ -141,7 +136,6 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
currencyFieldFormSchema,
dateFieldFormSchema,
dateTimeFieldFormSchema,
relationFieldFormSchema,
morphRelationFieldFormSchema,
selectFieldFormSchema,
multiSelectFieldFormSchema,
@@ -231,19 +225,12 @@ export const SettingsDataModelFieldSettingsFormCard = ({
);
}
if (fieldType === FieldMetadataType.RELATION) {
if (
fieldType === FieldMetadataType.RELATION ||
fieldType === FieldMetadataType.MORPH_RELATION
) {
return (
<SettingsDataModelFieldRelationSettingsFormCard
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
disabled={disabled}
/>
);
}
if (fieldType === FieldMetadataType.MORPH_RELATION) {
return (
<SettingsDataModelFieldMorphRelationFormCard
<SettingsDataModelFieldRelationFormCard
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
disabled={disabled}
@@ -10,7 +10,6 @@ import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields
import { type FieldType } from '@/settings/data-model/types/FieldType';
import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@@ -22,7 +21,6 @@ import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconSearch } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FeatureFlagKey } from '~/generated/graphql';
import { type SettingsDataModelFieldTypeFormValues } from '~/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect';
type SettingsObjectNewFieldSelectorProps = {
@@ -105,9 +103,7 @@ export const SettingsObjectNewFieldSelector = ({
break;
}
};
const isMorphRelationEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
);
return (
<>
{' '}
@@ -136,12 +132,18 @@ export const SettingsObjectNewFieldSelector = ({
<StyledContainer>
{fieldTypeConfigs
.filter(([, config]) => config.category === category)
.filter(([key]) => {
return (
key !== FieldMetadataType.MORPH_RELATION ||
isMorphRelationEnabled
);
})
// by default, we hide the relation type and create only the morph relation type
// on submit the new field, we choose the relation type based on the amount of target object
.filter(([key]) => key !== FieldMetadataType.RELATION)
.map(
([key, config]) =>
[
key,
key === FieldMetadataType.MORPH_RELATION
? { ...config, label: 'Relation' }
: config,
] as [string, SettingsFieldTypeConfig<any>],
)
.map(([key, config]) => (
<StyledCardContainer key={key}>
<UndecoratedLink
@@ -5,27 +5,55 @@ import { StyledContainer } from '@/keyboard-shortcut-menu/components/KeyboardSho
import { SettingsMorphRelationMultiSelect } from '@/settings/components/SettingsMorphRelationMultiSelect';
import { FIELD_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/FieldNameMaximumLength';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import { useMorphRelationSettingsFormDefaultValuesOnDestination } from '@/settings/data-model/fields/forms/morph-relation/hooks/useMorphRelationSettingsFormDefaultValuesOnDestination';
import { useMorphRelationSettingsFormInitialTargetMetadatas } from '@/settings/data-model/fields/forms/morph-relation/hooks/useMorphRelationSettingsFormInitialTargetMetadatas';
import { fieldMetadataItemDisableFieldEdition } from '@/settings/data-model/fields/forms/morph-relation/utils/fieldMetadataItemDisableFieldEdition';
import { useRelationSettingsFormDefaultValuesTargetFieldMetadata } from '@/settings/data-model/fields/forms/morph-relation/hooks/useRelationSettingsFormDefaultValuesTargetFieldMetadata';
import { useRelationSettingsFormInitialTargetObjectMetadatas } from '@/settings/data-model/fields/forms/morph-relation/hooks/useRelationSettingsFormInitialTargetObjectMetadatas';
import { useFieldMetadataItemDisableFieldEdition } from '@/settings/data-model/fields/forms/morph-relation/utils/fieldMetadataItemDisableFieldEdition';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import {
RELATION_TYPE_OPTIONS,
StyledInputsContainer,
StyledInputsLabel,
StyledSelectsContainer,
} from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
import { useIcons } from 'twenty-ui/display';
import { FeatureFlagKey, RelationType } from '~/generated-metadata/graphql';
const StyledSelectsContainer = styled.div<{ isMobile: boolean }>`
display: grid;
gap: ${({ theme }) => theme.spacing(4)};
grid-template-columns: ${({ isMobile }) => (isMobile ? '1fr' : '1fr 1fr')};
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledInputsLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const RELATION_TYPE_OPTIONS = Object.entries(RELATION_TYPES).map(
([value, { label, Icon }]) => ({
label,
value: value as RelationType,
Icon,
}),
);
export const settingsDataModelFieldMorphRelationFormSchema = z.object({
morphRelationObjectMetadataIds: z.array(z.uuid()).min(2),
morphRelationObjectMetadataIds: z.array(z.uuid()).min(1),
relationType: z.enum(
Object.keys(RELATION_TYPES) as [RelationType, ...RelationType[]],
),
@@ -37,29 +65,29 @@ export type SettingsDataModelFieldMorphRelationFormValues = z.infer<
typeof settingsDataModelFieldMorphRelationFormSchema
>;
type SettingsDataModelFieldMorphRelationFormProps = {
type SettingsDataModelFieldRelationFormProps = {
existingFieldMetadataId: string;
disabled?: boolean;
};
export const SettingsDataModelFieldMorphRelationForm = ({
export const SettingsDataModelFieldRelationForm = ({
existingFieldMetadataId,
disabled = false,
}: SettingsDataModelFieldMorphRelationFormProps) => {
}: SettingsDataModelFieldRelationFormProps) => {
const { t } = useLingui();
const { control } =
useFormContext<SettingsDataModelFieldMorphRelationFormValues>();
const { getIcon } = useIcons();
const { fieldMetadataItem: existingFieldMetadataItem } =
useFieldMetadataItemById(existingFieldMetadataId);
const disableRelationEdition = isDefined(existingFieldMetadataItem);
const disableFieldEdition = isDefined(existingFieldMetadataItem)
? fieldMetadataItemDisableFieldEdition(existingFieldMetadataItem)
: false;
const disableFieldEdition = useFieldMetadataItemDisableFieldEdition(
existingFieldMetadataItem,
);
const initialRelationObjectMetadataItems =
useMorphRelationSettingsFormInitialTargetMetadatas({
useRelationSettingsFormInitialTargetObjectMetadatas({
fieldMetadataItem: existingFieldMetadataItem,
});
@@ -68,7 +96,7 @@ export const SettingsDataModelFieldMorphRelationForm = ({
RelationType.ONE_TO_MANY;
const { label: defaultLabelOnDestination, icon: defaultIconOnDestination } =
useMorphRelationSettingsFormDefaultValuesOnDestination({
useRelationSettingsFormDefaultValuesTargetFieldMetadata({
fieldMetadataItem: existingFieldMetadataItem,
objectMetadataItem: initialRelationObjectMetadataItems[0],
relationType: initialRelationType,
@@ -80,6 +108,12 @@ export const SettingsDataModelFieldMorphRelationForm = ({
);
const isMobile = useIsMobile();
const isMorphRelationEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
);
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
return (
<StyledContainer>
<StyledSelectsContainer isMobile={isMobile}>
@@ -104,17 +138,40 @@ export const SettingsDataModelFieldMorphRelationForm = ({
name="morphRelationObjectMetadataIds"
control={control}
defaultValue={initialMorphRelationsObjectMetadataIds}
render={({ field: { onChange, value } }) => (
<SettingsMorphRelationMultiSelect
label={t`Object destination`}
dropdownId="object-destination-select"
fullWidth
disabled={disableRelationEdition}
selectedObjectMetadataIds={value}
withSearchInput={true}
onChange={onChange}
/>
)}
render={({ field: { onChange, value } }) =>
isMorphRelationEnabled ? (
<SettingsMorphRelationMultiSelect
label={t`Object destination`}
dropdownId="object-destination-select"
fullWidth
disabled={disableRelationEdition}
selectedObjectMetadataIds={value}
withSearchInput={true}
onChange={onChange}
/>
) : (
<Select
label={t`Object destination`}
dropdownId="object-destination-select"
fullWidth
disabled={disableRelationEdition}
value={value[0]}
options={activeObjectMetadataItems
.filter(isObjectMetadataAvailableForRelation)
.sort((item1, item2) =>
item1.labelSingular.localeCompare(item2.labelSingular),
)
.map((objectMetadataItem) => ({
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.id,
Icon: getIcon(objectMetadataItem.icon),
}))}
onChange={(value) => {
onChange([value]);
}}
/>
)
}
/>
</StyledSelectsContainer>
<StyledInputsLabel>{t`Field on destination`}</StyledInputsLabel>
@@ -4,28 +4,28 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import {
SettingsDataModelFieldMorphRelationForm,
SettingsDataModelFieldRelationForm,
type SettingsDataModelFieldMorphRelationFormValues,
} from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldMorphRelationForm';
import { SettingsDataModelFieldRelationPreviewContent } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationPreviewContent';
import { SettingsDataModelRelationPreviewImage } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationPreviewImageCard';
} from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm';
import { SettingsDataModelFieldRelationPreviewContent } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationPreviewContent';
import { SettingsDataModelRelationPreviewImage } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationPreviewImageCard';
import { SettingsDataModelRelationFieldPreviewSubWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreviewSubWidget';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldMorphRelationFormCardProps = {
type SettingsDataModelFieldRelationFormCardProps = {
existingFieldMetadataId: string;
objectNameSingular: string;
disabled?: boolean;
};
export const SettingsDataModelFieldMorphRelationFormCard = ({
export const SettingsDataModelFieldRelationFormCard = ({
existingFieldMetadataId,
objectNameSingular,
disabled = false,
}: SettingsDataModelFieldMorphRelationFormCardProps) => {
}: SettingsDataModelFieldRelationFormCardProps) => {
const { watch } = useFormContext<
SettingsDataModelFieldMorphRelationFormValues &
SettingsDataModelFieldEditFormValues
@@ -67,7 +67,7 @@ export const SettingsDataModelFieldMorphRelationFormCard = ({
fieldMetadataItem={{
icon: watch('icon'),
label: watch('label'),
type: FieldMetadataType.MORPH_RELATION,
type: FieldMetadataType.RELATION,
settings: {
relationType,
},
@@ -107,7 +107,7 @@ export const SettingsDataModelFieldMorphRelationFormCard = ({
</SettingsDataModelFieldRelationPreviewContent>
}
form={
<SettingsDataModelFieldMorphRelationForm
<SettingsDataModelFieldRelationForm
existingFieldMetadataId={existingFieldMetadataId}
disabled={disabled}
/>
@@ -1,16 +1,20 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { fieldMetadataItemHasMorphRelations } from '@/settings/data-model/fields/forms/morph-relation/utils/fieldMetadataItemHasMorphRelations';
import { RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
export const useMorphRelationSettingsFormDefaultValuesOnDestination = ({
export const useRelationSettingsFormDefaultValuesTargetFieldMetadata = ({
fieldMetadataItem,
objectMetadataItem,
relationType,
}: {
fieldMetadataItem?: Pick<FieldMetadataItem, 'type' | 'morphRelations'>;
fieldMetadataItem?: Pick<
FieldMetadataItem,
'type' | 'morphRelations' | 'relation'
>;
objectMetadataItem?: Pick<
ObjectMetadataItem,
'id' | 'namePlural' | 'nameSingular' | 'icon'
@@ -22,8 +26,15 @@ export const useMorphRelationSettingsFormDefaultValuesOnDestination = ({
} => {
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
if (!isDefined(fieldMetadataItem)) {
return {
icon: 'IconUsers',
label: '',
};
}
if (
isDefined(fieldMetadataItem) &&
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION &&
fieldMetadataItemHasMorphRelations(fieldMetadataItem)
) {
const firstMorphRelation = fieldMetadataItem.morphRelations?.[0];
@@ -38,11 +49,29 @@ export const useMorphRelationSettingsFormDefaultValuesOnDestination = ({
return {
icon: targetFieldMetadata?.icon ?? 'IconUsers',
label: targetFieldMetadata?.label ?? '',
label: capitalize(targetFieldMetadata?.label ?? ''),
};
}
}
if (
fieldMetadataItem.type === FieldMetadataType.RELATION &&
isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id)
) {
const { fieldMetadataItem: targetFieldMetadata } = getFieldMetadataItemById(
{
fieldMetadataId: fieldMetadataItem.relation?.targetFieldMetadata.id,
objectMetadataItems: activeObjectMetadataItems,
},
);
return {
icon: targetFieldMetadata?.icon ?? 'IconUsers',
label: capitalize(
fieldMetadataItem.relation?.targetFieldMetadata?.name ?? '',
),
};
}
if (isDefined(objectMetadataItem)) {
const label = [RelationType.MANY_TO_ONE].includes(relationType)
? objectMetadataItem.namePlural
@@ -50,7 +79,7 @@ export const useMorphRelationSettingsFormDefaultValuesOnDestination = ({
return {
icon: objectMetadataItem.icon ?? 'IconUsers',
label,
label: capitalize(label),
};
}
@@ -4,10 +4,13 @@ import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/is
import { fieldMetadataItemHasMorphRelations } from '@/settings/data-model/fields/forms/morph-relation/utils/fieldMetadataItemHasMorphRelations';
import { isDefined } from 'twenty-shared/utils';
export const useMorphRelationSettingsFormInitialTargetMetadatas = ({
export const useRelationSettingsFormInitialTargetObjectMetadatas = ({
fieldMetadataItem,
}: {
fieldMetadataItem?: Pick<FieldMetadataItem, 'type' | 'morphRelations'>;
fieldMetadataItem?: Pick<
FieldMetadataItem,
'type' | 'morphRelations' | 'relation'
>;
}) => {
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
@@ -27,6 +30,10 @@ export const useMorphRelationSettingsFormInitialTargetMetadatas = ({
);
}
if (isDefined(fieldMetadataItem) && isDefined(fieldMetadataItem.relation)) {
return [fieldMetadataItem.relation.targetObjectMetadata];
}
const availableItems = activeObjectMetadataItems
.filter(isObjectMetadataAvailableForRelation)
.sort((a, b) => a.labelSingular.localeCompare(b.labelSingular));
@@ -36,12 +43,6 @@ export const useMorphRelationSettingsFormInitialTargetMetadatas = ({
'Relation Form initialization error: invariant violated no valid object available for relation (this should never happen).',
);
}
const secondInitialObjectCandidate = availableItems[1];
if (!isDefined(secondInitialObjectCandidate)) {
throw new Error(
'Relation Form initialization error: invariant violated no valid object available for relation (this should never happen).',
);
}
return [firstInitialObjectCandidate, secondInitialObjectCandidate];
return [firstInitialObjectCandidate];
};
@@ -1,12 +1,29 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isDefined } from 'twenty-shared/utils';
export const fieldMetadataItemDisableFieldEdition = (
fieldMetadataItem: Pick<FieldMetadataItem, 'morphRelations'>,
export const useFieldMetadataItemDisableFieldEdition = (
fieldMetadataItem:
| Pick<FieldMetadataItem, 'morphRelations' | 'type' | 'id'>
| undefined,
) => {
const { fieldMetadataItem: fieldMetadataItemWithIsCustom } =
useFieldMetadataItemById(fieldMetadataItem?.id ?? '');
if (!isDefined(fieldMetadataItem)) {
return false;
}
const morphRelations = fieldMetadataItem?.morphRelations;
// TODO: @guillim to be updated with isCustom when available
if (isFieldRelation(fieldMetadataItem)) {
return (
isDefined(fieldMetadataItemWithIsCustom) &&
fieldMetadataItemWithIsCustom?.isCustom === true
);
}
return isDefined(morphRelations) && morphRelations.length > 0
? morphRelations.every(
(morphRelation) => !morphRelation.targetFieldMetadata.isCustom,
@@ -1,207 +0,0 @@
import styled from '@emotion/styled';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
import { FIELD_NAME_MAXIMUM_LENGTH } from '@/settings/data-model/constants/FieldNameMaximumLength';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import { useRelationSettingsFormInitialValues } from '@/settings/data-model/fields/forms/relation/hooks/useRelationSettingsFormInitialValues';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useLingui } from '@lingui/react/macro';
import { useIcons } from 'twenty-ui/display';
import { RelationType } from '~/generated-metadata/graphql';
export const settingsDataModelFieldRelationFormSchema = z.object({
relation: z.object({
field: fieldMetadataItemSchema()
.pick({
icon: true,
label: true,
})
// NOT SURE IF THIS IS CORRECT
.extend(
fieldMetadataItemSchema()
.pick({
name: true,
isLabelSyncedWithName: true,
})
.partial().shape,
),
objectMetadataId: z.uuid(),
type: z.enum(
Object.keys(RELATION_TYPES) as [RelationType, ...RelationType[]],
),
}),
});
export type SettingsDataModelFieldRelationFormValues = z.infer<
typeof settingsDataModelFieldRelationFormSchema
>;
type SettingsDataModelFieldRelationFormProps = {
existingFieldMetadataId: string;
objectMetadataItem?: ObjectMetadataItem;
disabled?: boolean;
};
export const StyledContainer = styled.div`
padding: ${({ theme }) => theme.spacing(4)};
`;
export const StyledSelectsContainer = styled.div<{ isMobile: boolean }>`
display: grid;
gap: ${({ theme }) => theme.spacing(4)};
grid-template-columns: ${({ isMobile }) => (isMobile ? '1fr' : '1fr 1fr')};
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
export const StyledInputsLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
export const StyledInputsContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export const RELATION_TYPE_OPTIONS = Object.entries(RELATION_TYPES).map(
([value, { label, Icon }]) => ({
label,
value: value as RelationType,
Icon,
}),
);
export const SettingsDataModelFieldRelationForm = ({
existingFieldMetadataId,
objectMetadataItem,
disabled,
}: SettingsDataModelFieldRelationFormProps) => {
const { t } = useLingui();
const { control, watch: watchFormValue } =
useFormContext<SettingsDataModelFieldRelationFormValues>();
const { getIcon } = useIcons();
const { activeObjectMetadataItems, findObjectMetadataItemById } =
useFilteredObjectMetadataItems();
const {
disableFieldEdition,
disableRelationEdition,
initialRelationFieldMetadataItem,
initialRelationObjectMetadataItem,
initialRelationType,
} = useRelationSettingsFormInitialValues({
existingFieldMetadataId,
objectMetadataItem,
});
const selectedObjectMetadataItem = findObjectMetadataItemById(
watchFormValue(
'relation.objectMetadataId',
initialRelationObjectMetadataItem?.id,
),
);
const selectedRelationType = watchFormValue(
'relation.type',
initialRelationType,
);
const isMobile = useIsMobile();
return (
<StyledContainer>
<StyledSelectsContainer isMobile={isMobile}>
<Controller
name="relation.type"
control={control}
defaultValue={initialRelationType}
render={({ field: { onChange, value } }) => (
<Select
label={t`Relation type`}
dropdownId="relation-type-select"
fullWidth
disabled={disabled || disableRelationEdition}
value={value}
options={RELATION_TYPE_OPTIONS}
onChange={onChange}
/>
)}
/>
<Controller
name="relation.objectMetadataId"
control={control}
defaultValue={initialRelationObjectMetadataItem.id}
render={({ field: { onChange, value } }) => (
<Select
label={t`Object destination`}
dropdownId="object-destination-select"
fullWidth
disabled={disabled || disableRelationEdition}
value={value}
options={activeObjectMetadataItems
.filter(isObjectMetadataAvailableForRelation)
.sort((item1, item2) =>
item1.labelPlural.localeCompare(item2.labelPlural),
)
.map((objectMetadataItem) => ({
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.id,
Icon: getIcon(objectMetadataItem.icon),
}))}
onChange={onChange}
/>
)}
/>
</StyledSelectsContainer>
<StyledInputsLabel>
Field on{' '}
{selectedRelationType === RelationType.MANY_TO_ONE
? selectedObjectMetadataItem?.labelSingular
: selectedObjectMetadataItem?.labelPlural}
</StyledInputsLabel>
<StyledInputsContainer>
<Controller
name="relation.field.icon"
control={control}
defaultValue={initialRelationFieldMetadataItem.icon}
render={({ field: { onChange, value } }) => (
<IconPicker
disabled={disabled || disableFieldEdition}
dropdownId="field-destination-icon-picker"
selectedIconKey={value ?? undefined}
onChange={({ iconKey }) => onChange(iconKey)}
variant="primary"
/>
)}
/>
<Controller
name="relation.field.label"
control={control}
defaultValue={initialRelationFieldMetadataItem.label}
render={({ field: { onChange, value } }) => (
<SettingsTextInput
instanceId="relation-field-label"
disabled={disabled || disableFieldEdition}
placeholder={t`Field name`}
value={value}
onChange={onChange}
fullWidth
maxLength={FIELD_NAME_MAXIMUM_LENGTH}
/>
)}
/>
</StyledInputsContainer>
</StyledContainer>
);
};
@@ -1,105 +0,0 @@
import { useFormContext } from 'react-hook-form';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import {
SettingsDataModelFieldRelationForm,
type SettingsDataModelFieldRelationFormValues,
} from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm';
import { SettingsDataModelFieldRelationPreviewContent } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationPreviewContent';
import { SettingsDataModelRelationPreviewImage } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationPreviewImageCard';
import { SettingsDataModelRelationFieldPreviewSubWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreviewSubWidget';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldRelationSettingsFormCardProps = {
existingFieldMetadataId: string;
objectNameSingular: string;
disabled?: boolean;
};
export const SettingsDataModelFieldRelationSettingsFormCard = ({
existingFieldMetadataId,
objectNameSingular,
disabled = false,
}: SettingsDataModelFieldRelationSettingsFormCardProps) => {
const { watch } = useFormContext<
SettingsDataModelFieldRelationFormValues &
SettingsDataModelFieldEditFormValues
>();
const isMobile = useIsMobile();
const { objectMetadataItems } = useObjectMetadataItems();
const relationObjectMetadataId = watch('relation.objectMetadataId');
const relationObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === relationObjectMetadataId,
);
const relationTargetField = watch('relation.field');
const relationType: RelationType = watch('relation.type');
const relationTypeConfig = RELATION_TYPES[relationType];
const oppositeRelationType =
relationType === RelationType.MANY_TO_ONE
? RelationType.ONE_TO_MANY
: RelationType.MANY_TO_ONE;
return (
<SettingsDataModelPreviewFormCard
preview={
<SettingsDataModelFieldRelationPreviewContent isMobile={isMobile}>
<SettingsDataModelRelationFieldPreviewSubWidget
fieldMetadataItem={{
icon: watch('icon'),
label: watch('label'),
type: FieldMetadataType.RELATION,
settings: {
relationType: relationType,
},
}}
shrink
objectNameSingulars={[objectNameSingular]}
fieldPreviewTargetObjectNameSingular={
relationObjectMetadataItem?.nameSingular ?? 'company'
}
pluralizeLabel={relationType === RelationType.ONE_TO_MANY}
/>
<SettingsDataModelRelationPreviewImage
src={relationTypeConfig?.imageSrc}
flip={relationTypeConfig?.isImageFlipped}
alt={relationTypeConfig?.label}
isMobile={isMobile}
/>
<SettingsDataModelRelationFieldPreviewSubWidget
fieldMetadataItem={{
icon: relationTargetField?.icon,
label: relationTargetField?.label,
type: FieldMetadataType.RELATION,
settings: {
relationType: oppositeRelationType,
},
}}
shrink
objectNameSingulars={[
relationObjectMetadataItem?.nameSingular ??
CoreObjectNameSingular.Company,
]}
fieldPreviewTargetObjectNameSingular={objectNameSingular}
pluralizeLabel={oppositeRelationType === RelationType.ONE_TO_MANY}
/>
</SettingsDataModelFieldRelationPreviewContent>
}
form={
<SettingsDataModelFieldRelationForm
existingFieldMetadataId={existingFieldMetadataId}
objectMetadataItem={relationObjectMetadataItem}
disabled={disabled}
/>
}
/>
);
};
@@ -1,76 +0,0 @@
import { useMemo } from 'react';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
type UseRelationSettingsFormInitialValuesProps = {
existingFieldMetadataId: string;
objectMetadataItem?: Pick<
ObjectMetadataItem,
'id' | 'icon' | 'labelSingular' | 'labelPlural'
>;
};
export const useRelationSettingsFormInitialValues = ({
existingFieldMetadataId,
objectMetadataItem,
}: UseRelationSettingsFormInitialValuesProps) => {
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
const getRelationMetadata = useGetRelationMetadata();
const {
relationFieldMetadataItem,
relationObjectMetadataItem: relationObjectMetadataItemFromFieldMetadata,
relationType: relationTypeFromFieldMetadata,
} = useMemo(
() =>
fieldMetadataItem ? getRelationMetadata({ fieldMetadataItem }) : null,
[fieldMetadataItem, getRelationMetadata],
) ?? {};
const initialRelationObjectMetadataItem = useMemo(() => {
const availableItems = activeObjectMetadataItems.filter(
isObjectMetadataAvailableForRelation,
);
const initialObjectCandidate =
relationObjectMetadataItemFromFieldMetadata ??
objectMetadataItem ??
availableItems[0];
if (!isDefined(initialObjectCandidate)) {
throw new Error(
'Relation Form initialization error: invariant violated no valid object available for relation (this should never happen).',
);
}
return initialObjectCandidate;
}, [
objectMetadataItem,
activeObjectMetadataItems,
relationObjectMetadataItemFromFieldMetadata,
]);
const initialRelationType =
relationTypeFromFieldMetadata ?? RelationType.ONE_TO_MANY;
return {
disableFieldEdition:
isDefined(relationFieldMetadataItem) &&
relationFieldMetadataItem?.isCustom === true,
disableRelationEdition: !!relationFieldMetadataItem,
initialRelationFieldMetadataItem: relationFieldMetadataItem ?? {
icon: initialRelationObjectMetadataItem.icon ?? 'IconUsers',
label: [RelationType.MANY_TO_ONE].includes(initialRelationType)
? initialRelationObjectMetadataItem.labelPlural
: initialRelationObjectMetadataItem.labelSingular,
},
initialRelationObjectMetadataItem,
initialRelationType,
};
};
@@ -74,6 +74,7 @@ export const SettingsDataModelRelationFieldPreview = ({
const recordId = `${relationTargetObjectNameSingular}-${fieldName}-preview`;
const isRelation = fieldMetadataItem.type === FieldMetadataType.RELATION;
const metadata = {
fieldName,
objectMetadataNameSingular: CoreObjectNameSingular.Company,
@@ -107,6 +108,11 @@ export const SettingsDataModelRelationFieldPreview = ({
} satisfies FieldMetadataItemRelation,
]
: [],
relationFieldMetadataId: isRelation ? '' : v4(),
relationObjectMetadataId: isRelation ? '' : v4(),
relationObjectMetadataNamePlural: isRelation
? ''
: relationTargetObjectMetadataItem.namePlural,
};
return (