Morph-front-display-table (#13979)

## Add Morph Relation Field Display Support for Tables (one to many &
many to one)

**Features:**
- Display morph relation fields (one-to-many, many-to-one) in table
cells
- Multi-field record retrieval for improved performance
- Enhanced table cell rendering with morph relation support

**Components Added:**
- `MorphRelationManyToOneFieldDisplay` - Shows many-to-one morph
relations
- `MorphRelationOneToManyFieldDisplay` - Shows one-to-many morph
relations


TODO : 
handle notes and tasks case if we choose these kind of objects as
targets/source for a morph relation

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-08-25 16:12:49 +02:00
committed by GitHub
parent dff00e813c
commit 3d1842dcb3
75 changed files with 1656 additions and 1098 deletions
@@ -229,3 +229,5 @@ idealCustomerProfile
);
});
});
// todo @guillim add a test for the morph relation
@@ -1,5 +1,4 @@
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { isUndefined } from '@sniptt/guards';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -7,7 +6,11 @@ import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObject
import { type RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { isNonCompositeField } from '@/object-record/object-filter-dropdown/utils/isNonCompositeField';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
computeMorphRelationFieldJoinColumnName,
computeMorphRelationFieldName,
isDefined,
} from 'twenty-shared/utils';
import { type FieldMetadataItem } from '../types/FieldMetadataItem';
type MapFieldMetadataToGraphQLQueryArgs = {
@@ -15,7 +18,7 @@ type MapFieldMetadataToGraphQLQueryArgs = {
gqlField: string;
fieldMetadata: Pick<
FieldMetadataItem,
'name' | 'type' | 'relation' | 'settings'
'name' | 'type' | 'relation' | 'morphRelations' | 'settings'
>;
relationRecordGqlFields?: RecordGqlFields;
computeReferences?: boolean;
@@ -41,6 +44,90 @@ export const mapFieldMetadataToGraphQLQuery = ({
return gqlField;
}
if (
fieldType === FieldMetadataType.MORPH_RELATION &&
(fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY ||
fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE)
) {
let gqlMorphField = '';
for (const morphRelation of fieldMetadata.morphRelations ?? []) {
const relationFieldName = computeMorphRelationFieldName({
fieldName: fieldMetadata.name,
relationDirection: fieldMetadata.settings?.relationType,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
});
const relationMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === morphRelation.targetObjectMetadata.id,
);
if (!isDefined(relationMetadataItem)) {
continue;
}
if (
isDefined(objectPermissionsByObjectMetadataId) &&
isDefined(relationMetadataItem.id)
) {
if (!isDefined(morphRelation.targetObjectMetadata.id)) {
throw new Error(
`Target object metadata id not found with field metadata ${fieldMetadata.name}`,
);
}
const objectPermission = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
morphRelation.targetObjectMetadata.id,
);
if (!objectPermission.canReadObjectRecords) {
continue;
}
}
if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) {
gqlMorphField += `${relationFieldName}
{
edges {
node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem: relationMetadataItem,
recordGqlFields: relationRecordGqlFields,
computeReferences,
isRootLevel: false,
objectPermissionsByObjectMetadataId,
})}
}
}`;
}
if (fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE) {
const joinColumnName = computeMorphRelationFieldJoinColumnName({
name: fieldMetadata.name,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
});
if (gqlField === joinColumnName) {
gqlMorphField += `${gqlField}
`;
continue;
}
gqlMorphField += `${relationFieldName}
${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem: relationMetadataItem,
recordGqlFields: relationRecordGqlFields,
computeReferences,
isRootLevel: false,
objectPermissionsByObjectMetadataId,
})}`;
}
}
return `${gqlMorphField}`;
}
if (
fieldType === FieldMetadataType.RELATION &&
fieldMetadata.relation?.type === RelationType.MANY_TO_ONE
@@ -51,7 +138,7 @@ export const mapFieldMetadataToGraphQLQuery = ({
fieldMetadata.relation?.targetObjectMetadata.id,
);
if (isUndefined(relationMetadataItem)) {
if (!isDefined(relationMetadataItem)) {
return '';
}
@@ -100,7 +187,7 @@ ${mapObjectMetadataToGraphQLQuery({
fieldMetadata.relation?.targetObjectMetadata.id,
);
if (isUndefined(relationMetadataItem)) {
if (!isDefined(relationMetadataItem)) {
return '';
}
@@ -5,7 +5,7 @@ import { shouldFieldBeQueried } from '@/object-metadata/utils/shouldFieldBeQueri
import { type RecordGqlFields } from '@/object-record/graphql/types/RecordGqlFields';
import { isRecordGqlFieldsNode } from '@/object-record/graphql/utils/isRecordGraphlFieldsNode';
import { FieldMetadataType, type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
type MapObjectMetadataToGraphQLQueryArgs = {
objectMetadataItems: ObjectMetadataItem[];
@@ -46,22 +46,75 @@ export const mapObjectMetadataToGraphQLQuery = ({
const manyToOneRelationFields = objectMetadataItem?.readableFields
.filter((field) => field.isActive)
.filter((field) => field.type === FieldMetadataType.RELATION)
.filter(
(field) =>
field.type === FieldMetadataType.RELATION ||
field.type === FieldMetadataType.MORPH_RELATION,
)
.filter((field) => isDefined(field.settings?.joinColumnName));
const manyToOneRelationGqlFieldWithFieldMetadata =
manyToOneRelationFields.map((field) => ({
gqlField: field.settings?.joinColumnName,
fieldMetadata: field,
manyToOneRelationFields.flatMap((field) => {
const isMorphRelation = field.type === FieldMetadataType.MORPH_RELATION;
if (!isMorphRelation) {
return {
gqlField: field.settings?.joinColumnName,
fieldMetadata: field,
};
}
if (!isDefined(field.morphRelations)) {
throw new Error(
`Field ${field.name} is missing, please refresh the page. If the problem persists, please contact support.`,
);
}
return field.morphRelations.map((morphRelation) => ({
gqlField: computeMorphRelationFieldName({
fieldName: field.name,
relationDirection: morphRelation.type,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata: field,
}));
});
const readableFields = objectMetadataItem.readableFields.filter(
(fieldMetadata) => fieldMetadata.isActive,
);
const activeReadableFields = readableFields.flatMap((fieldMetadata) => {
const isMorphRelation =
fieldMetadata.type === FieldMetadataType.MORPH_RELATION;
if (!isMorphRelation) {
return [
{
gqlField: fieldMetadata.name,
fieldMetadata,
},
];
}
if (!isDefined(fieldMetadata.morphRelations)) {
throw new Error(
`Field ${fieldMetadata.name} is missing, please refresh the page. If the problem persists, please contact support.`,
);
}
return fieldMetadata.morphRelations.map((morphRelation) => ({
gqlField: computeMorphRelationFieldName({
fieldName: fieldMetadata.name,
relationDirection: morphRelation.type,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
}),
fieldMetadata,
}));
});
const gqlFieldWithFieldMetadataThatCouldBeQueried = [
...objectMetadataItem.readableFields
.filter((fieldMetadata) => fieldMetadata.isActive)
.map((fieldMetadata) => ({
gqlField: fieldMetadata.name,
fieldMetadata,
})),
...activeReadableFields,
...manyToOneRelationGqlFieldWithFieldMetadata,
].sort((gqlFieldWithFieldMetadataA, gqlFieldWithFieldMetadataB) =>
gqlFieldWithFieldMetadataA.gqlField.localeCompare(
@@ -1,8 +1,8 @@
import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataItem } from '../types/FieldMetadataItem';
@@ -18,16 +18,21 @@ export const shouldFieldBeQueried = ({
recordGqlFields?: RecordGqlOperationGqlRecordFields;
}): any => {
const isJoinColumn: boolean =
isFieldRelation(fieldMetadata) &&
(isFieldRelation(fieldMetadata) || isFieldMorphRelation(fieldMetadata)) &&
fieldMetadata.settings.joinColumnName === gqlField;
if (
isUndefinedOrNull(recordGqlFields) &&
(fieldMetadata.type !== FieldMetadataType.RELATION || isJoinColumn)
!isFieldRelation(fieldMetadata) &&
!isFieldMorphRelation(fieldMetadata)
) {
return true;
}
if (isUndefinedOrNull(recordGqlFields) && isJoinColumn) {
return true;
}
if (
isDefined(recordGqlFields) &&
isDefined(recordGqlFields[gqlField]) &&
@@ -9,7 +9,11 @@ export const generateDepthOneWithoutRelationsRecordGqlFields = ({
objectMetadataItem,
}: GenerateDepthOneWithoutRelationsRecordGqlFields) => {
return objectMetadataItem.fields
.filter((field) => field.type !== FieldMetadataType.RELATION)
.filter(
(field) =>
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION,
)
.reduce<Record<string, true>>((acc, field) => {
return {
...acc,
@@ -23,6 +23,11 @@ import { isFieldRelationFromManyObjects } from '@/object-record/record-field/ui/
import { isFieldRelationToOneObject } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneObject';
import { isFieldRichText } from '@/object-record/record-field/ui/types/guards/isFieldRichText';
import { isFieldRichTextV2 } from '@/object-record/record-field/ui/types/guards/isFieldRichTextV2';
import { MorphRelationManyToOneFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/MorphRelationManyToOneFieldDisplay';
import { MorphRelationOneToManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/MorphRelationOneToManyFieldDisplay';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
import { isDefined } from 'twenty-shared/utils';
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
@@ -73,6 +78,10 @@ export const FieldDisplay = () => {
<RelationToOneFieldDisplay />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldDisplay />
) : isFieldMorphRelationManyToOne(fieldDefinition) ? (
<MorphRelationManyToOneFieldDisplay />
) : isFieldMorphRelationOneToMany(fieldDefinition) ? (
<MorphRelationOneToManyFieldDisplay />
) : isFieldText(fieldDefinition) ? (
<TextFieldDisplay />
) : isFieldUuid(fieldDefinition) ? (
@@ -0,0 +1,33 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordChip } from '@/object-record/components/RecordChip';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useMorphRelationToOneFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useMorphRelationToOneFieldDisplay';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const MorphRelationManyToOneFieldDisplay = () => {
const { morphFieldValuesWithObjectName } =
useMorphRelationToOneFieldDisplay();
const { disableChipClick, triggerEvent } = useContext(FieldContext);
if (!isDefined(morphFieldValuesWithObjectName?.value)) {
return null;
}
const isWorkspaceMemberFieldMetadataRelation =
morphFieldValuesWithObjectName.objectNameSingular ===
CoreObjectNameSingular.WorkspaceMember;
return (
<RecordChip
key={morphFieldValuesWithObjectName.value.id}
objectNameSingular={morphFieldValuesWithObjectName.objectNameSingular}
record={morphFieldValuesWithObjectName.value}
forceDisableClick={
isWorkspaceMemberFieldMetadataRelation || disableChipClick
}
triggerEvent={triggerEvent}
/>
);
};
@@ -0,0 +1,45 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
import { useMorphRelationFromManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useMorphRelationFromManyFieldDisplay';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { Fragment, useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const MorphRelationOneToManyFieldDisplay = () => {
const { morphValuesWithObjectNameSingular } =
useMorphRelationFromManyFieldDisplay();
const { isFocused } = useFieldFocus();
const { disableChipClick, triggerEvent } = useContext(FieldContext);
if (!morphValuesWithObjectNameSingular) {
return null;
}
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{morphValuesWithObjectNameSingular
.filter(isDefined)
.map((morphValueWithObjectNameSingular) => {
return (
<Fragment key={morphValueWithObjectNameSingular.objectNameSingular}>
{morphValueWithObjectNameSingular.value.map((record) => {
return (
<RecordChip
key={record.id}
objectNameSingular={
morphValueWithObjectNameSingular.objectNameSingular
}
record={record}
forceDisableClick={disableChipClick}
triggerEvent={triggerEvent}
/>
);
})}
</Fragment>
);
})}
</ExpandableList>
);
};
@@ -0,0 +1,61 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { recordStoreMorphOneToManyValueWithObjectNameFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreMorphOneToManyValueWithObjectNameFamilySelector';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
export const useMorphRelationFromManyFieldDisplay = () => {
const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext);
const { chipGeneratorPerObjectPerField } = useContext(
PreComputedChipGeneratorsContext,
);
if (!isDefined(chipGeneratorPerObjectPerField)) {
throw new Error('Chip generator per object per field is not defined');
}
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
const button = fieldDefinition.editButtonIcon;
const morphValuesWithObjectNameSingular = useRecoilValue(
recordStoreMorphOneToManyValueWithObjectNameFamilySelector({
recordId,
morphRelations: fieldDefinition.metadata.morphRelations,
fieldName: fieldDefinition.metadata.fieldName,
}),
);
const maxWidthForField =
isDefined(button) && isDefined(maxWidth)
? maxWidth - FIELD_EDIT_BUTTON_WIDTH
: maxWidth;
if (
!isDefined(fieldDefinition.metadata.objectMetadataNameSingular) ||
!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)
) {
throw new Error('Object metadata name singular is not a non-empty string');
}
return {
fieldDefinition,
morphValuesWithObjectNameSingular,
maxWidth: maxWidthForField,
recordId,
};
};
@@ -0,0 +1,78 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { recordStoreMorphManyToOneValueWithObjectNameFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreMorphManyToOneValueWithObjectNameFamilySelector';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useMorphRelationToOneFieldDisplay = () => {
const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext);
const { chipGeneratorPerObjectPerField } = useContext(
PreComputedChipGeneratorsContext,
);
if (!isDefined(chipGeneratorPerObjectPerField)) {
throw new Error('Chip generator per object per field is not defined');
}
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
const button = fieldDefinition.editButtonIcon;
const fieldName = fieldDefinition.metadata.fieldName;
const morphFieldValueWithObjectName = useRecoilValue(
recordStoreMorphManyToOneValueWithObjectNameFamilySelector({
recordId,
morphRelations: fieldDefinition.metadata.morphRelations,
fieldName,
}),
);
if (!isDefined(morphFieldValueWithObjectName)) {
return {
fieldDefinition,
fieldValues: [],
maxWidth: maxWidth,
recordId,
generateRecordChipData: () => ({
recordId: '',
objectNameSingular: '',
}),
};
}
const maxWidthForField =
isDefined(button) && isDefined(maxWidth)
? maxWidth - FIELD_EDIT_BUTTON_WIDTH
: maxWidth;
if (
!isDefined(fieldDefinition.metadata.objectMetadataNameSingular) ||
!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)
) {
throw new Error('Object metadata name singular is not a non-empty string');
}
return {
fieldDefinition,
morphFieldValuesWithObjectName: morphFieldValueWithObjectName,
maxWidth: maxWidthForField,
recordId,
};
};
@@ -48,7 +48,7 @@ export const useRelationToOneFieldDisplay = () => {
!isDefined(fieldDefinition.metadata.objectMetadataNameSingular) ||
!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)
) {
throw new Error('Object metadata name singular is not a non-empty string');
throw new Error('Object metadata name singular is empty');
}
const fieldChipGenerator =
@@ -10,4 +10,4 @@ export const isFieldMorphRelationManyToOne = (
field: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldMorphRelationMetadata> =>
isFieldMorphRelation(field) &&
field.metadata.morphRelations?.[0].type === RelationType.MANY_TO_ONE;
field.metadata.relationType === RelationType.MANY_TO_ONE;
@@ -5,6 +5,8 @@ import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/g
import { generateDepthOneWithoutRelationsRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneWithoutRelationsRecordGqlFields';
import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export const useRecordTableRecordGqlFields = ({
objectMetadataItem,
@@ -30,11 +32,41 @@ export const useRecordTableRecordGqlFields = ({
objectMetadataItem,
});
const gqlFieldsList = Object.fromEntries(
visibleTableColumns.flatMap((column) => {
const isMorphRelation = column.type === FieldMetadataType.MORPH_RELATION;
if (!isMorphRelation) {
return [[column.metadata.fieldName, true]];
}
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === column.fieldMetadataId,
);
if (
!isDefined(fieldMetadataItem) ||
!isDefined(fieldMetadataItem.morphRelations)
) {
throw new Error(
`Field ${column.metadata.fieldName} is missing, please refresh the page. If the problem persists, please contact support.`,
);
}
return fieldMetadataItem.morphRelations.map((morphRelation) => [
computeMorphRelationFieldName({
fieldName: fieldMetadataItem.name,
relationDirection: morphRelation.type,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
}),
true,
]);
}),
);
const recordGqlFields: Record<string, any> = {
...allDepthOneWithoutRelationsRecordGqlFields,
...Object.fromEntries(
visibleTableColumns.map((column) => [column.metadata.fieldName, true]),
),
...gqlFieldsList,
noteTargets: generateDepthOneRecordGqlFields({
objectMetadataItem: noteTargetObjectMetadataItem,
}),
@@ -0,0 +1,46 @@
import { selectorFamily } from 'recoil';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RelationType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export const recordStoreMorphManyToOneValueWithObjectNameFamilySelector =
selectorFamily({
key: 'recordStoreMorphManyToOneValueWithObjectNameFamilySelector',
get:
({
fieldName,
recordId,
morphRelations,
}: {
fieldName: string;
recordId: string;
morphRelations: FieldMetadataItemRelation[];
}) =>
({ get }) => {
const morphValuesWithObjectName = morphRelations.map(
(morphRelation) => {
const computedFieldName = computeMorphRelationFieldName({
fieldName,
relationDirection: RelationType.MANY_TO_ONE,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
});
return {
objectNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
value: get(recordStoreFamilyState(recordId))?.[
computedFieldName
] as ObjectRecord,
};
},
);
const morphValueFiltered = morphValuesWithObjectName.filter(
(morphValue) => isDefined(morphValue.value),
);
return morphValueFiltered.length > 0 ? morphValueFiltered[0] : null;
},
});
@@ -0,0 +1,43 @@
import { selectorFamily } from 'recoil';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RelationType } from 'twenty-shared/types';
import { computeMorphRelationFieldName } from 'twenty-shared/utils';
export const recordStoreMorphOneToManyValueWithObjectNameFamilySelector =
selectorFamily({
key: 'recordStoreMorphOneToManyValueWithObjectNameFamilySelector',
get:
({
fieldName,
recordId,
morphRelations,
}: {
fieldName: string;
recordId: string;
morphRelations: FieldMetadataItemRelation[];
}) =>
({ get }) => {
const morphValuesWithObjectName = morphRelations.map(
(morphRelation) => {
const computedFieldName = computeMorphRelationFieldName({
fieldName,
relationDirection: RelationType.ONE_TO_MANY,
nameSingular: morphRelation.targetObjectMetadata.nameSingular,
namePlural: morphRelation.targetObjectMetadata.namePlural,
});
return {
objectNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
value: (get(recordStoreFamilyState(recordId))?.[
computedFieldName
] || []) as ObjectRecord[],
};
},
);
return morphValuesWithObjectName;
},
});
@@ -29,7 +29,7 @@ export const RecordTableCellFieldContextGeneric = ({
const updateRecord = useContext(RecordUpdateContext);
let hasObjectReadPermissions = objectPermissions.canReadObjectRecords;
// todo @guillim : adjust this to handle morph relations permissions display
if (
isFieldRelationToOneObject(columnDefinition) ||
isFieldRelationFromManyObjects(columnDefinition)
@@ -4,7 +4,10 @@ import { assertUnreachable } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
export type GenerateEmptyFieldValueArgs = {
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'relation'>;
fieldMetadataItem: Pick<
FieldMetadataItem,
'type' | 'settings' | 'defaultValue'
>;
shouldComputeFunctionDefaultValue?: boolean;
};
// TODO strictly type each fieldValue following their FieldMetadataType
@@ -62,11 +65,13 @@ export const generateEmptyFieldValue = ({
return null;
}
case FieldMetadataType.BOOLEAN: {
return true;
return fieldMetadataItem?.defaultValue ?? true;
}
case FieldMetadataType.RELATION:
case FieldMetadataType.MORPH_RELATION: {
if (fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE) {
if (
fieldMetadataItem.settings?.relationType === RelationType.MANY_TO_ONE
) {
return null;
}
@@ -1,7 +1,6 @@
import { Separator } from '@/settings/components/Separator';
import { Controller, useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
addressSchema as addressFieldDefaultValueSchema,
addressSettingsSchema,
@@ -30,10 +29,7 @@ import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFro
type SettingsDataModelFieldAddressFormProps = {
disabled?: boolean;
defaultCountry?: string;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
existingFieldMetadataId: string;
};
export const settingsDataModelFieldAddressFormSchema = z.object({
@@ -47,7 +43,7 @@ export type SettingsDataModelFieldTextFormValues = z.infer<
export const SettingsDataModelFieldAddressForm = ({
disabled,
fieldMetadataItem,
existingFieldMetadataId,
}: SettingsDataModelFieldAddressFormProps) => {
const { t } = useLingui();
const { control } = useFormContext<SettingsDataModelFieldTextFormValues>();
@@ -66,33 +62,23 @@ export const SettingsDataModelFieldAddressForm = ({
Flag({ width: props.size, height: props.size }),
})),
];
const { initialDisplaySubFields, resetDefaultValueField } =
useAddressSettingsFormInitialValues({ fieldMetadataItem });
const {
initialDisplaySubFields,
initialDefaultValue,
resetDefaultValueField,
} = useAddressSettingsFormInitialValues({ existingFieldMetadataId });
const { closeDropdown } = useCloseDropdown();
const reset = () => {
resetDefaultValueField();
closeDropdown('addressSubFieldsId');
};
const defaultDefaultValue = {
addressStreet1: "''",
addressStreet2: null,
addressCity: null,
addressState: null,
addressPostcode: null,
addressCountry: null,
addressLat: null,
addressLng: null,
};
return (
<>
<Controller
name="defaultValue"
defaultValue={{
...defaultDefaultValue,
...fieldMetadataItem?.defaultValue,
}}
defaultValue={initialDefaultValue}
control={control}
render={({ field: { onChange, value } }) => {
const defaultCountry = value?.addressCountry || '';
@@ -1,62 +1,46 @@
import styled from '@emotion/styled';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
SettingsDataModelFieldAddressForm,
type SettingsDataModelFieldTextFormValues,
} from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm';
import { useAddressSettingsFormInitialValues } from '@/settings/data-model/fields/forms/address/hooks/useAddressSettingsFormInitialValues';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldAddressSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldAddressSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldAddressSettingsFormCardProps) => {
const { initialDisplaySubFields } = useAddressSettingsFormInitialValues({
fieldMetadataItem,
});
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldTextFormValues>();
const { watch } = useFormContext<
SettingsDataModelFieldTextFormValues & SettingsDataModelFieldEditFormValues
>();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
...fieldMetadataItem,
icon: watch('icon'),
label: watch('label'),
type: FieldMetadataType.ADDRESS,
settings: {
...fieldMetadataItem.settings,
subFields: watchFormValue(
'settings.subFields',
initialDisplaySubFields,
),
subFields: watch('settings.subFields'),
},
}}
objectMetadataItem={objectMetadataItem}
objectNameSingular={objectNameSingular}
/>
}
form={
<SettingsDataModelFieldAddressForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
/>
}
/>
@@ -1,6 +1,9 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { renderHook } from '@testing-library/react';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from 'twenty-shared/types';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { useAddressSettingsFormInitialValues } from '../useAddressSettingsFormInitialValues';
jest.mock('react-hook-form', () => ({
@@ -12,6 +15,11 @@ const mockUseFormContext = useFormContext as jest.MockedFunction<
typeof useFormContext
>;
const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
objectMetadataItems: generatedMockObjectMetadataItems,
});
describe('useAddressSettingsFormInitialValues', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -21,8 +29,14 @@ describe('useAddressSettingsFormInitialValues', () => {
});
it('should return all address subfields when no fieldMetadataItem is provided', () => {
const { result } = renderHook(() =>
useAddressSettingsFormInitialValues({}),
const { result } = renderHook(
() =>
useAddressSettingsFormInitialValues({
existingFieldMetadataId: 'new-field',
}),
{
wrapper: Wrapper,
},
);
expect(result.current.initialDisplaySubFields).toEqual([
@@ -36,12 +50,14 @@ describe('useAddressSettingsFormInitialValues', () => {
});
it('should return all address subfields when fieldMetadataItem has no settings', () => {
const fieldMetadataItem: Pick<FieldMetadataItem, 'settings'> = {
settings: undefined,
};
const { result } = renderHook(() =>
useAddressSettingsFormInitialValues({ fieldMetadataItem }),
const { result } = renderHook(
() =>
useAddressSettingsFormInitialValues({
existingFieldMetadataId: 'new-field',
}),
{
wrapper: Wrapper,
},
);
expect(result.current.initialDisplaySubFields).toEqual([
@@ -55,14 +71,20 @@ describe('useAddressSettingsFormInitialValues', () => {
});
it('should return all address subfields when settings.subFields is null', () => {
const fieldMetadataItem: Pick<FieldMetadataItem, 'settings'> = {
settings: {
subFields: null,
},
};
// const fieldMetadataItem: Pick<FieldMetadataItem, 'settings'> = {
// settings: {
// subFields: null,
// },
// };
const { result } = renderHook(() =>
useAddressSettingsFormInitialValues({ fieldMetadataItem }),
const { result } = renderHook(
() =>
useAddressSettingsFormInitialValues({
existingFieldMetadataId: 'new-field',
}),
{
wrapper: Wrapper,
},
);
expect(result.current.initialDisplaySubFields).toEqual([
@@ -76,14 +98,20 @@ describe('useAddressSettingsFormInitialValues', () => {
});
it('should return all address subfields when settings.subFields is empty array', () => {
const fieldMetadataItem: Pick<FieldMetadataItem, 'settings'> = {
settings: {
subFields: [],
},
};
// const fieldMetadataItem: Pick<FieldMetadataItem, 'settings'> = {
// settings: {
// subFields: [],
// },
// };
const { result } = renderHook(() =>
useAddressSettingsFormInitialValues({ fieldMetadataItem }),
const { result } = renderHook(
() =>
useAddressSettingsFormInitialValues({
existingFieldMetadataId: 'new-field',
}),
{
wrapper: Wrapper,
},
);
expect(result.current.initialDisplaySubFields).toEqual([
@@ -97,14 +125,25 @@ describe('useAddressSettingsFormInitialValues', () => {
});
it('should return configured subFields when they exist', () => {
const fieldMetadataItem: Pick<FieldMetadataItem, 'settings'> = {
settings: {
subFields: ['addressStreet1', 'addressCity', 'addressCountry'],
},
};
const newGeneratedMockObjectMetadataItems = addNewAddressToMetadataItems(
generatedMockObjectMetadataItems,
'new-field',
['addressStreet1', 'addressCity', 'addressCountry'],
);
const { result } = renderHook(() =>
useAddressSettingsFormInitialValues({ fieldMetadataItem }),
const WrapperSpecific = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
objectMetadataItems: newGeneratedMockObjectMetadataItems,
});
const { result } = renderHook(
() =>
useAddressSettingsFormInitialValues({
existingFieldMetadataId: 'new-field',
}),
{
wrapper: WrapperSpecific,
},
);
expect(result.current.initialDisplaySubFields).toEqual([
@@ -115,8 +154,14 @@ describe('useAddressSettingsFormInitialValues', () => {
});
it('should call resetField with all address subFields when resetDefaultValueField is called', () => {
const { result } = renderHook(() =>
useAddressSettingsFormInitialValues({}),
const { result } = renderHook(
() =>
useAddressSettingsFormInitialValues({
existingFieldMetadataId: 'new-field',
}),
{
wrapper: Wrapper,
},
);
result.current.resetDefaultValueField();
@@ -134,14 +179,22 @@ describe('useAddressSettingsFormInitialValues', () => {
});
it('should handle partial subFields configuration', () => {
const fieldMetadataItem: Pick<FieldMetadataItem, 'settings'> = {
settings: {
subFields: ['addressStreet1', 'addressCity'],
const WrapperSpecific = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
objectMetadataItems: addNewAddressToMetadataItems(
generatedMockObjectMetadataItems,
'new-field',
['addressStreet1', 'addressCity'],
),
});
const { result } = renderHook(
() =>
useAddressSettingsFormInitialValues({
existingFieldMetadataId: 'new-field',
}),
{
wrapper: WrapperSpecific,
},
};
const { result } = renderHook(() =>
useAddressSettingsFormInitialValues({ fieldMetadataItem }),
);
expect(result.current.initialDisplaySubFields).toEqual([
@@ -150,3 +203,28 @@ describe('useAddressSettingsFormInitialValues', () => {
]);
});
});
const addNewAddressToMetadataItems = (
generatedMockObjectMetadataItems: ObjectMetadataItem[],
fieldNameId: string,
subFields: string[],
) => {
return generatedMockObjectMetadataItems
.filter((item) => item.nameSingular === 'company')
.map((item) => {
const fields = item.fields;
const addressField = fields.find(
(field) => field.type === FieldMetadataType.ADDRESS,
);
if (!addressField) {
throw new Error('Address field not found');
}
const newField = {
...addressField,
id: fieldNameId,
type: FieldMetadataType.ADDRESS,
settings: { subFields },
};
return { ...item, fields: [...fields, newField] };
});
};
@@ -1,13 +1,19 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { type SettingsDataModelFieldTextFormValues } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm';
import { DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES } from '@/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages';
import { useFormContext } from 'react-hook-form';
type UseAddressSettingsFormInitialValuesProps = {
existingFieldMetadataId: string;
};
export const useAddressSettingsFormInitialValues = ({
fieldMetadataItem,
}: {
fieldMetadataItem?: Pick<FieldMetadataItem, 'settings'>;
}) => {
existingFieldMetadataId,
}: UseAddressSettingsFormInitialValuesProps) => {
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
const allAddressSubFields = DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES.map(
(selectionAddres) => selectionAddres.value,
);
@@ -17,6 +23,19 @@ export const useAddressSettingsFormInitialValues = ({
? fieldMetadataItem.settings.subFields
: allAddressSubFields;
const defaultDefaultValue = {
addressStreet1: "''",
addressStreet2: null,
addressCity: null,
addressState: null,
addressPostcode: null,
addressCountry: null,
addressLat: null,
addressLng: null,
};
const initialDefaultValue =
fieldMetadataItem?.defaultValue ?? defaultDefaultValue;
const { resetField } = useFormContext<SettingsDataModelFieldTextFormValues>();
const resetDefaultValueField = () => {
@@ -26,6 +45,7 @@ export const useAddressSettingsFormInitialValues = ({
};
return {
initialDefaultValue,
initialDisplaySubFields,
resetDefaultValueField,
};
@@ -1,7 +1,6 @@
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { BOOLEAN_DATA_MODEL_SELECT_OPTIONS } from '@/settings/data-model/fields/forms/boolean/constants/BooleanDataModelSelectOptions';
import { useBooleanSettingsFormInitialValues } from '@/settings/data-model/fields/forms/boolean/hooks/useBooleanSettingsFormInitialValues';
@@ -18,17 +17,17 @@ export type SettingsDataModelFieldBooleanFormValues = z.infer<
>;
type SettingsDataModelFieldBooleanFormProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'defaultValue'>;
existingFieldMetadataId: string;
};
export const SettingsDataModelFieldBooleanForm = ({
fieldMetadataItem,
existingFieldMetadataId,
}: SettingsDataModelFieldBooleanFormProps) => {
const { t } = useLingui();
const { control } = useFormContext<SettingsDataModelFieldBooleanFormValues>();
const { initialDefaultValue } = useBooleanSettingsFormInitialValues({
fieldMetadataItem,
existingFieldMetadataId,
});
return (
@@ -1,55 +1,44 @@
import { useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
SettingsDataModelFieldBooleanForm,
type SettingsDataModelFieldBooleanFormValues,
} from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm';
import { useBooleanSettingsFormInitialValues } from '@/settings/data-model/fields/forms/boolean/hooks/useBooleanSettingsFormInitialValues';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { FieldMetadataType } from 'twenty-shared/types';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldBooleanSettingsFormCardProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldBooleanSettingsFormCard = ({
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldBooleanSettingsFormCardProps) => {
const { initialDefaultValue } = useBooleanSettingsFormInitialValues({
fieldMetadataItem,
});
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldBooleanFormValues>();
const { watch } = useFormContext<
SettingsDataModelFieldBooleanFormValues &
SettingsDataModelFieldEditFormValues
>();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
...fieldMetadataItem,
defaultValue: watchFormValue('defaultValue', initialDefaultValue),
type: FieldMetadataType.BOOLEAN,
label: watch('label'),
icon: watch('icon'),
defaultValue: watch('defaultValue'),
}}
objectMetadataItem={objectMetadataItem}
objectNameSingular={objectNameSingular}
/>
}
form={
<SettingsDataModelFieldBooleanForm
fieldMetadataItem={fieldMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
/>
}
/>
@@ -1,13 +1,19 @@
import { useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { type SettingsDataModelFieldBooleanFormValues } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm';
type UseBooleanSettingsFormInitialValuesProps = {
existingFieldMetadataId: string;
};
export const useBooleanSettingsFormInitialValues = ({
fieldMetadataItem,
}: {
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue'>;
}) => {
existingFieldMetadataId,
}: UseBooleanSettingsFormInitialValuesProps) => {
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
const initialDefaultValue =
(fieldMetadataItem?.defaultValue as SettingsDataModelFieldBooleanFormValues['defaultValue']) ??
true;
@@ -1,12 +1,12 @@
import { Controller, useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { Separator } from '@/settings/components/Separator';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { canBeUnique } from '@/settings/data-model/fields/forms/utils/canBeUnique.util';
import { t } from '@lingui/core/macro';
import { type FieldMetadataType } from 'twenty-shared/types';
import { IconKey } from 'twenty-ui/display';
import { Toggle } from 'twenty-ui/input';
@@ -15,31 +15,42 @@ type SettingsDataModelFieldIsUniqueFormValues = {
};
type SettingsDataModelFieldIsUniqueFormProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'isCustom' | 'settings' | 'isUnique'
> &
Partial<{ id: string }>;
objectMetadataItem: Pick<ObjectMetadataItem, 'indexMetadatas'>;
objectNameSingular: string;
fieldType: FieldMetadataType;
existingFieldMetadataId: string;
};
export const SettingsDataModelFieldIsUniqueForm = ({
fieldMetadataItem,
objectMetadataItem,
fieldType,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldIsUniqueFormProps) => {
const { control } =
useFormContext<SettingsDataModelFieldIsUniqueFormValues>();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
const hasStandardUniqueIndex = objectMetadataItem.indexMetadatas.some(
(index) =>
index.isUnique &&
!index.isCustom &&
index.indexFieldMetadatas?.some(
(field) => field.fieldMetadataId === fieldMetadataItem.id,
(field) => field.fieldMetadataId === existingFieldMetadataId,
),
);
if (!canBeUnique(fieldMetadataItem)) {
if (
!canBeUnique({
type: fieldType,
isCustom: fieldMetadataItem?.isCustom ?? true,
})
) {
return null;
}
@@ -1,8 +1,6 @@
import styled from '@emotion/styled';
import omit from 'lodash.omit';
import { z } from 'zod';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
import { settingsDataModelFieldAddressFormSchema } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm';
@@ -30,11 +28,10 @@ import {
settingsDataModelFieldSelectFormSchema,
} from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm';
import { SettingsDataModelFieldSelectSettingsFormCard } from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectSettingsFormCard';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
const isUniqueFieldFormSchema = z.object({
isUnique: z.boolean().nullable().default(false),
@@ -136,16 +133,10 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
);
type SettingsDataModelFieldSettingsFormCardProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'isCustom' | 'settings'
> &
Partial<Omit<FieldMetadataItem, 'icon' | 'label' | 'type'>>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
existingFieldMetadataId: string;
fieldType: FieldMetadataType;
objectNameSingular: string;
};
const previewableTypes = [
FieldMetadataType.ARRAY,
@@ -170,105 +161,110 @@ const previewableTypes = [
];
export const SettingsDataModelFieldSettingsFormCard = ({
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
fieldType,
objectNameSingular,
}: SettingsDataModelFieldSettingsFormCardProps) => {
if (!previewableTypes.includes(fieldMetadataItem.type)) {
const { watch } = useFormContext<SettingsDataModelFieldEditFormValues>();
if (!previewableTypes.includes(fieldType)) {
return null;
}
if (fieldMetadataItem.type === FieldMetadataType.BOOLEAN) {
if (fieldType === FieldMetadataType.BOOLEAN) {
return (
<SettingsDataModelFieldBooleanSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
);
}
if (fieldMetadataItem.type === FieldMetadataType.CURRENCY) {
if (fieldType === FieldMetadataType.CURRENCY) {
return (
<SettingsDataModelFieldCurrencySettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
);
}
if (
fieldMetadataItem.type === FieldMetadataType.DATE ||
fieldMetadataItem.type === FieldMetadataType.DATE_TIME
fieldType === FieldMetadataType.DATE ||
fieldType === FieldMetadataType.DATE_TIME
) {
return (
<SettingsDataModelFieldDateSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
fieldType={fieldType}
objectNameSingular={objectNameSingular}
/>
);
}
if (fieldMetadataItem.type === FieldMetadataType.RELATION) {
if (fieldType === FieldMetadataType.RELATION) {
return (
<SettingsDataModelFieldRelationSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
);
}
if (fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION) {
if (fieldType === FieldMetadataType.MORPH_RELATION) {
return (
<SettingsDataModelFieldMorphRelationFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
);
}
if (fieldMetadataItem.type === FieldMetadataType.NUMBER) {
if (fieldType === FieldMetadataType.NUMBER) {
return (
<SettingsDataModelFieldNumberSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
);
}
if (fieldMetadataItem.type === FieldMetadataType.TEXT) {
if (fieldType === FieldMetadataType.TEXT) {
return (
<SettingsDataModelFieldTextSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
);
}
if (fieldMetadataItem.type === FieldMetadataType.ADDRESS) {
if (fieldType === FieldMetadataType.ADDRESS) {
return (
<SettingsDataModelFieldAddressSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
);
}
if (fieldMetadataItem.type === FieldMetadataType.PHONES) {
if (fieldType === FieldMetadataType.PHONES) {
return (
<SettingsDataModelFieldPhonesSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
);
}
if (
fieldMetadataItem.type === FieldMetadataType.SELECT ||
fieldMetadataItem.type === FieldMetadataType.MULTI_SELECT
fieldType === FieldMetadataType.SELECT ||
fieldType === FieldMetadataType.MULTI_SELECT
) {
return (
<SettingsDataModelFieldSelectSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
fieldType={fieldType}
objectNameSingular={objectNameSingular}
/>
);
}
@@ -276,15 +272,22 @@ export const SettingsDataModelFieldSettingsFormCard = ({
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
type: fieldType,
label: watch('label'),
icon: watch('icon'),
defaultValue: watch('defaultValue'),
settings: watch('settings'),
}}
objectNameSingular={objectNameSingular}
/>
}
form={
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
fieldType={fieldType}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
}
/>
@@ -64,7 +64,6 @@ const StyledSearchInput = styled(SettingsTextInput)`
export const SettingsObjectNewFieldSelector = ({
excludedFieldTypes = [],
fieldMetadataItem,
objectNamePlural,
}: SettingsObjectNewFieldSelectorProps) => {
const theme = useTheme();
@@ -80,13 +79,15 @@ export const SettingsObjectNewFieldSelector = ({
);
const { resetDefaultValueField: resetBooleanDefaultValueField } =
useBooleanSettingsFormInitialValues({ fieldMetadataItem });
useBooleanSettingsFormInitialValues({ existingFieldMetadataId: 'new' });
const { resetDefaultValueField: resetCurrencyDefaultValueField } =
useCurrencySettingsFormInitialValues({ fieldMetadataItem });
useCurrencySettingsFormInitialValues({ existingFieldMetadataId: 'new' });
const { resetDefaultValueField: resetSelectDefaultValueField } =
useSelectSettingsFormInitialValues({ fieldMetadataItem });
useSelectSettingsFormInitialValues({
fieldMetadataId: 'new',
});
const resetDefaultValueField = (nextValue: SettingsFieldType) => {
switch (nextValue) {
@@ -37,8 +37,9 @@ const meta: Meta<typeof SettingsDataModelFieldSettingsFormCard> = {
I18nFrontDecorator,
],
args: {
fieldMetadataItem,
objectMetadataItem: mockedCompanyObjectMetadataItem,
existingFieldMetadataId: fieldMetadataItem.id,
fieldType: FieldMetadataType.TEXT,
objectNameSingular: mockedCompanyObjectMetadataItem.nameSingular,
},
parameters: {
container: { width: 512 },
@@ -53,18 +54,16 @@ export const Default: Story = {};
export const WithRelationForm: Story = {
args: {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ name }) => name === 'people',
),
existingFieldMetadataId: 'new-field',
fieldType: FieldMetadataType.RELATION,
objectNameSingular: 'company',
},
};
export const WithSelectForm: Story = {
args: {
fieldMetadataItem: {
label: 'Industry',
icon: 'IconBuildingFactory2',
type: FieldMetadataType.SELECT,
},
existingFieldMetadataId: 'new-field',
fieldType: FieldMetadataType.SELECT,
objectNameSingular: 'company',
},
};
@@ -1,6 +1,6 @@
import { Controller, useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { TEXT_DATA_MODEL_SELECT_OPTIONS } from '@/settings/data-model/fields/forms/components/text/constants/TextDataModelSelectOptions';
import { Select } from '@/ui/input/components/Select';
@@ -10,16 +10,7 @@ import { z } from 'zod';
type SettingsDataModelFieldTextFormProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
| 'icon'
| 'label'
| 'type'
| 'defaultValue'
| 'settings'
| 'isUnique'
| 'isCustom'
>;
existingFieldMetadataId: string;
};
const textFieldDefaultValueSchema = z.object({
@@ -36,16 +27,20 @@ export type SettingsDataModelFieldTextFormValues = z.infer<
export const SettingsDataModelFieldTextForm = ({
disabled,
fieldMetadataItem,
existingFieldMetadataId,
}: SettingsDataModelFieldTextFormProps) => {
const { t } = useLingui();
const { fieldMetadataItem: existingFieldMetadataItem } =
useFieldMetadataItemById(existingFieldMetadataId);
const { control } = useFormContext<SettingsDataModelFieldTextFormValues>();
return (
<Controller
name="settings"
defaultValue={{
displayedMaxRows: fieldMetadataItem?.settings?.displayedMaxRows || 0,
displayedMaxRows:
existingFieldMetadataItem?.settings?.displayedMaxRows || 0,
}}
control={control}
render={({ field: { onChange, value } }) => {
@@ -1,56 +1,47 @@
import styled from '@emotion/styled';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldTextForm } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from 'twenty-shared/types';
type SettingsDataModelFieldTextSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'isCustom' | 'settings'
> &
Partial<{ id: string }>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldTextSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldTextSettingsFormCardProps) => {
const { watch } = useFormContext();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
...fieldMetadataItem,
label: watch('label'),
icon: watch('icon'),
type: FieldMetadataType.TEXT,
settings: watch('settings'),
}}
objectMetadataItem={objectMetadataItem}
objectNameSingular={objectNameSingular}
/>
}
form={
<>
<SettingsDataModelFieldTextForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
fieldType={FieldMetadataType.TEXT}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
</>
}
@@ -1,7 +1,6 @@
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldCurrencyFormat } from '@/object-record/record-field/ui/types/FieldMetadata';
import { currencyFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/currencyFieldDefaultValueSchema';
import { currencyFieldSettingsSchema } from '@/object-record/record-field/ui/validation-schemas/currencyFieldSettingsSchema';
@@ -24,15 +23,12 @@ export type SettingsDataModelFieldCurrencyFormValues = z.infer<
type SettingsDataModelFieldCurrencyFormProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
existingFieldMetadataId: string;
};
export const SettingsDataModelFieldCurrencyForm = ({
disabled,
fieldMetadataItem,
existingFieldMetadataId,
}: SettingsDataModelFieldCurrencyFormProps) => {
const { t } = useLingui();
const {
@@ -40,7 +36,7 @@ export const SettingsDataModelFieldCurrencyForm = ({
initialCurrencyCodeValue,
initialSettingsValue,
} = useCurrencySettingsFormInitialValues({
fieldMetadataItem,
existingFieldMetadataId,
});
const { control } =
useFormContext<SettingsDataModelFieldCurrencyFormValues>();
@@ -1,60 +1,48 @@
import styled from '@emotion/styled';
import { useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
SettingsDataModelFieldCurrencyForm,
type SettingsDataModelFieldCurrencyFormValues,
} from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencyForm';
import { useCurrencySettingsFormInitialValues } from '@/settings/data-model/fields/forms/currency/hooks/useCurrencySettingsFormInitialValues';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { FieldMetadataType } from 'twenty-shared/types';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldCurrencySettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldCurrencySettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldCurrencySettingsFormCardProps) => {
const { initialDefaultValue, initialSettingsValue } =
useCurrencySettingsFormInitialValues({
fieldMetadataItem,
});
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldCurrencyFormValues>();
const { watch } = useFormContext<
SettingsDataModelFieldCurrencyFormValues &
SettingsDataModelFieldEditFormValues
>();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
...fieldMetadataItem,
defaultValue: watchFormValue('defaultValue', initialDefaultValue),
settings: watchFormValue('settings', initialSettingsValue),
type: FieldMetadataType.CURRENCY,
label: watch('label'),
icon: watch('icon'),
defaultValue: watch('defaultValue'),
settings: watch('settings'),
}}
objectMetadataItem={objectMetadataItem}
objectNameSingular={objectNameSingular}
/>
}
form={
<SettingsDataModelFieldCurrencyForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
/>
}
/>
@@ -1,21 +1,30 @@
import { useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { CurrencyCode } from '@/object-record/record-field/ui/types/CurrencyCode';
import { type SettingsDataModelFieldCurrencyFormValues } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencyForm';
import { isNonEmptyString } from '@sniptt/guards';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
type UseCurrencySettingsFormInitialValuesArgs = {
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'settings'>;
existingFieldMetadataId: string;
};
export const useCurrencySettingsFormInitialValues = ({
fieldMetadataItem,
existingFieldMetadataId,
}: UseCurrencySettingsFormInitialValuesArgs) => {
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
const initialAmountMicrosValue =
(fieldMetadataItem?.defaultValue?.amountMicros as number | null) ?? null;
const initialCurrencyCodeValue =
fieldMetadataItem?.defaultValue?.currencyCode ??
applySimpleQuotesToString(CurrencyCode.USD);
const initialCurrencyCodeValue = isNonEmptyString(
stripSimpleQuotesFromString(fieldMetadataItem?.defaultValue?.currencyCode),
)
? fieldMetadataItem?.defaultValue?.currencyCode
: applySimpleQuotesToString(CurrencyCode.USD);
const initialFormValues: SettingsDataModelFieldCurrencyFormValues = {
settings: {
format: fieldMetadataItem?.settings?.format ?? 'short',
@@ -2,7 +2,6 @@ import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { validateCustomDateFormat } from '@/localization/utils/validateCustomDateFormat';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldDateDisplayFormat } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isDateFieldCustomDisplayFormat } from '@/object-record/record-field/ui/types/guards/isDateFIeldCustomDisplayFormat';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
@@ -45,12 +44,12 @@ export type SettingsDataModelFieldDateFormValues = z.infer<
type SettingsDataModelFieldDateFormProps = {
disabled?: boolean;
fieldMetadataItem: Pick<FieldMetadataItem, 'settings'>;
existingFieldMetadataId: string;
};
export const SettingsDataModelFieldDateForm = ({
disabled,
fieldMetadataItem,
existingFieldMetadataId,
}: SettingsDataModelFieldDateFormProps) => {
const { t } = useLingui();
@@ -59,7 +58,7 @@ export const SettingsDataModelFieldDateForm = ({
const { initialDisplayFormat, initialCustomUnicodeDateFormat } =
useDateSettingsFormInitialValues({
fieldMetadataItem,
fieldMetadataId: existingFieldMetadataId,
});
const displayFormatFromForm = watch('settings.displayFormat');
@@ -1,75 +1,60 @@
import styled from '@emotion/styled';
import { useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import {
SettingsDataModelFieldDateForm,
type SettingsDataModelFieldDateFormValues,
} from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
import { useDateSettingsFormInitialValues } from '@/settings/data-model/fields/forms/date/hooks/useDateSettingsFormInitialValues';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { type FieldMetadataType } from 'twenty-shared/types';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldDateSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'isCustom' | 'settings' | 'isUnique'
> &
Partial<{ id: string }>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
fieldType: FieldMetadataType.DATE_TIME | FieldMetadataType.DATE;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldDateSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
fieldType,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldDateSettingsFormCardProps) => {
const { initialDisplayFormat, initialCustomUnicodeDateFormat } =
useDateSettingsFormInitialValues({
fieldMetadataItem,
});
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldDateFormValues>();
const { watch } = useFormContext<
SettingsDataModelFieldDateFormValues & SettingsDataModelFieldEditFormValues
>();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
...fieldMetadataItem,
type: fieldType,
label: watch('label'),
icon: watch('icon'),
settings: {
displayFormat: watchFormValue(
'settings.displayFormat',
initialDisplayFormat,
),
customUnicodeDateFormat: watchFormValue(
displayFormat: watch('settings.displayFormat'),
customUnicodeDateFormat: watch(
'settings.customUnicodeDateFormat',
initialCustomUnicodeDateFormat,
),
},
}}
objectMetadataItem={objectMetadataItem}
objectNameSingular={objectNameSingular}
/>
}
form={
<>
<SettingsDataModelFieldDateForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
fieldType={fieldType}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
</>
}
@@ -1,14 +1,18 @@
import { useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { FieldDateDisplayFormat } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type SettingsDataModelFieldDateFormValues } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
type UseDateSettingsFormInitialValuesProps = {
fieldMetadataId: string;
};
export const useDateSettingsFormInitialValues = ({
fieldMetadataItem,
}: {
fieldMetadataItem?: Pick<FieldMetadataItem, 'settings'>;
}) => {
fieldMetadataId,
}: UseDateSettingsFormInitialValuesProps) => {
const { fieldMetadataItem } = useFieldMetadataItemById(fieldMetadataId);
const initialDisplayFormat =
(fieldMetadataItem?.settings?.displayFormat as FieldDateDisplayFormat) ??
FieldDateDisplayFormat.USER_SETTINGS;
@@ -2,16 +2,14 @@ import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { StyledContainer } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenuStyles';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
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 { fieldMetadataItemHasMorphRelations } from '@/settings/data-model/fields/forms/morph-relation/utils/fieldMetadataItemHasMorphRelations';
import { fieldMetadataItemInitialRelationType } from '@/settings/data-model/fields/forms/morph-relation/utils/fieldMetadataItemInitialRelationType';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import {
RELATION_TYPE_OPTIONS,
StyledInputsContainer,
@@ -23,7 +21,8 @@ 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 { type RelationType } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
export const settingsDataModelFieldMorphRelationFormSchema = z.object({
morphRelationObjectMetadataIds: z.array(z.string().uuid()).min(2),
@@ -39,33 +38,36 @@ export type SettingsDataModelFieldMorphRelationFormValues = z.infer<
>;
type SettingsDataModelFieldMorphRelationFormProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'morphRelations'>;
existingFieldMetadataId: string;
};
export const SettingsDataModelFieldMorphRelationForm = ({
fieldMetadataItem,
existingFieldMetadataId,
}: SettingsDataModelFieldMorphRelationFormProps) => {
const { t } = useLingui();
const { control } =
useFormContext<SettingsDataModelFieldMorphRelationFormValues>();
const morphRelationPreviouslyCreated =
fieldMetadataItemHasMorphRelations(fieldMetadataItem);
const { fieldMetadataItem: existingFieldMetadataItem } =
useFieldMetadataItemById(existingFieldMetadataId);
const disableRelationEdition = isDefined(existingFieldMetadataItem);
const disableFieldEdition = isDefined(existingFieldMetadataItem)
? fieldMetadataItemDisableFieldEdition(existingFieldMetadataItem)
: false;
const disableRelationEdition = !!morphRelationPreviouslyCreated;
const disableFieldEdition =
fieldMetadataItemDisableFieldEdition(fieldMetadataItem);
const initialRelationObjectMetadataItems =
useMorphRelationSettingsFormInitialTargetMetadatas({
fieldMetadataItem,
fieldMetadataItem: existingFieldMetadataItem,
});
const initialRelationType =
fieldMetadataItemInitialRelationType(fieldMetadataItem);
existingFieldMetadataItem?.settings?.relationType ??
RelationType.ONE_TO_MANY;
const { label: defaultLabelOnDestination, icon: defaultIconOnDestination } =
useMorphRelationSettingsFormDefaultValuesOnDestination({
fieldMetadataItem,
fieldMetadataItem: existingFieldMetadataItem,
objectMetadataItem: initialRelationObjectMetadataItems[0],
relationType: initialRelationType,
});
@@ -1,132 +1,82 @@
import styled from '@emotion/styled';
import { useFormContext } from 'react-hook-form';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import {
SettingsDataModelFieldMorphRelationForm,
type SettingsDataModelFieldMorphRelationFormValues,
} from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldMorphRelationForm';
import { useMorphRelationSettingsFormInitialTargetMetadatas } from '@/settings/data-model/fields/forms/morph-relation/hooks/useMorphRelationSettingsFormInitialTargetMetadatas';
import { fieldMetadataItemInitialRelationType } from '@/settings/data-model/fields/forms/morph-relation/utils/fieldMetadataItemInitialRelationType';
import { SettingsDataModelFieldRelationPreviewContent } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationPreviewContent';
import { SettingsDataModelRelationPreviewImage } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationPreviewImageCard';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelMorphRelationFieldPreviewCard } from '@/settings/data-model/fields/preview/components/SettingsDataModelMorphRelationFieldPreviewCard';
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,
type Relation,
} from '~/generated-metadata/graphql';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldMorphRelationFormCardProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> &
Partial<Omit<FieldMetadataItem, 'icon' | 'label' | 'type'>>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
export const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
const StyledMorphRelationFieldPreviewCard = styled(
SettingsDataModelMorphRelationFieldPreviewCard,
)`
flex: 1 1 100%;
`;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldMorphRelationFormCard = ({
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldMorphRelationFormCardProps) => {
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldMorphRelationFormValues>();
const { findObjectMetadataItemById } = useFilteredObjectMetadataItems();
const { watch } = useFormContext<
SettingsDataModelFieldMorphRelationFormValues &
SettingsDataModelFieldEditFormValues
>();
const isMobile = useIsMobile();
const initialRelationType =
fieldMetadataItemInitialRelationType(fieldMetadataItem);
const initialRelationObjectMetadataItems =
useMorphRelationSettingsFormInitialTargetMetadatas({
fieldMetadataItem,
});
const initialRelationFieldMetadataItem = {
icon: initialRelationObjectMetadataItems[0].icon,
label: [RelationType.MANY_TO_ONE].includes(initialRelationType)
? initialRelationObjectMetadataItems[0].namePlural
: initialRelationObjectMetadataItems[0].nameSingular,
};
const initialRelationObjectMetadataItem =
initialRelationObjectMetadataItems[0];
const { objectMetadataItems } = useObjectMetadataItems();
const relationObjectMetadataId = watchFormValue(
'morphRelationObjectMetadataIds.0',
initialRelationObjectMetadataItem?.id,
);
const relationObjectMetadataItem = findObjectMetadataItemById(
relationObjectMetadataId,
const relationObjectMetadataIds: string[] = watch(
'morphRelationObjectMetadataIds',
[],
);
if (!relationObjectMetadataItem) return null;
const relationObjectMetadataItems = relationObjectMetadataIds
.map((relationObjectMetadataId) =>
objectMetadataItems.find((item) => item.id === relationObjectMetadataId),
)
.filter(isDefined);
const relationType: RelationType = watchFormValue(
const fallbackRelationObjectMetadataItem = objectMetadataItems[0];
const relationType: RelationType = watch(
'relationType',
initialRelationType,
RelationType.ONE_TO_MANY,
);
const relationTypeConfig = RELATION_TYPES[relationType];
if (!isDefined(relationTypeConfig)) return null;
const oppositeRelationType =
relationType === RelationType.MANY_TO_ONE
? RelationType.ONE_TO_MANY
: RelationType.MANY_TO_ONE;
const morphRelationsForStyledFieldPreviewCard = watchFormValue(
'morphRelationObjectMetadataIds',
)?.map((morphRelationObjectMetadataId) => {
const relationObjectMetadataItem = findObjectMetadataItemById(
morphRelationObjectMetadataId,
);
if (!relationObjectMetadataItem)
throw new Error('Relation object metadata item not found');
return {
targetObjectMetadata: {
id: relationObjectMetadataItem.id,
nameSingular: relationObjectMetadataItem.labelSingular,
labelSingular: relationObjectMetadataItem.labelSingular,
labelPlural: relationObjectMetadataItem.labelPlural,
icon: relationObjectMetadataItem.icon,
isCustom: relationObjectMetadataItem.isCustom,
isRemote: relationObjectMetadataItem.isRemote,
},
};
});
return (
<SettingsDataModelPreviewFormCard
preview={
<SettingsDataModelFieldRelationPreviewContent isMobile={isMobile}>
<StyledFieldPreviewCard
<SettingsDataModelRelationFieldPreviewSubWidget
fieldMetadataItem={{
...fieldMetadataItem,
relation: {
type: relationType,
} as Relation,
icon: watch('icon'),
label: watch('label'),
type: FieldMetadataType.MORPH_RELATION,
settings: {
relationType,
},
}}
shrink
objectMetadataItem={objectMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
pluralizeLabel={
watchFormValue('relationType') === RelationType.MANY_TO_ONE
objectNameSingulars={[objectNameSingular]}
fieldPreviewTargetObjectNameSingular={
relationObjectMetadataItems[0]?.nameSingular ??
fallbackRelationObjectMetadataItem.nameSingular
}
pluralizeLabel={watch('relationType') === RelationType.MANY_TO_ONE}
/>
<SettingsDataModelRelationPreviewImage
src={relationTypeConfig.imageSrc}
@@ -134,38 +84,29 @@ export const SettingsDataModelFieldMorphRelationFormCard = ({
alt={relationTypeConfig.label}
isMobile={isMobile}
/>
{morphRelationsForStyledFieldPreviewCard && (
<StyledMorphRelationFieldPreviewCard
fieldMetadataItem={{
...initialRelationFieldMetadataItem,
icon: watchFormValue(
'iconOnDestination',
initialRelationFieldMetadataItem.icon ?? undefined,
),
label:
watchFormValue(
'targetFieldLabel',
initialRelationFieldMetadataItem.label,
) || 'Field name',
type: FieldMetadataType.RELATION,
relation: {
type: oppositeRelationType,
} as Relation,
morphRelations: morphRelationsForStyledFieldPreviewCard,
}}
shrink
objectMetadataItem={relationObjectMetadataItem}
relationObjectMetadataItem={objectMetadataItem}
pluralizeLabel={
watchFormValue('relationType') !== RelationType.MANY_TO_ONE
}
/>
)}
<SettingsDataModelRelationFieldPreviewSubWidget
fieldMetadataItem={{
icon: watch('iconOnDestination'),
label: watch('targetFieldLabel'),
type: FieldMetadataType.RELATION,
settings: {
relationType: oppositeRelationType,
},
}}
shrink
objectNameSingulars={
relationObjectMetadataItems.length > 0
? relationObjectMetadataItems.map((item) => item.nameSingular)
: [fallbackRelationObjectMetadataItem.nameSingular]
}
fieldPreviewTargetObjectNameSingular={objectNameSingular}
pluralizeLabel={watch('relationType') !== RelationType.MANY_TO_ONE}
/>
</SettingsDataModelFieldRelationPreviewContent>
}
form={
<SettingsDataModelFieldMorphRelationForm
fieldMetadataItem={fieldMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
/>
}
/>
@@ -1,12 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const fieldMetadataItemInitialRelationType = (
fieldMetadataItem: Pick<FieldMetadataItem, 'morphRelations'>,
) => {
const morphRelations = fieldMetadataItem?.morphRelations;
return isDefined(morphRelations) && morphRelations.length > 0
? morphRelations[0].type
: RelationType.ONE_TO_MANY;
};
@@ -1,7 +1,7 @@
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { numberFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/numberFieldDefaultValueSchema';
import { Separator } from '@/settings/components/Separator';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
@@ -22,19 +22,20 @@ export type SettingsDataModelFieldNumberFormValues = z.infer<
type SettingsDataModelFieldNumberFormProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
existingFieldMetadataId: string;
};
export const SettingsDataModelFieldNumberForm = ({
disabled,
fieldMetadataItem,
existingFieldMetadataId,
}: SettingsDataModelFieldNumberFormProps) => {
const { t } = useLingui();
const { control } = useFormContext<SettingsDataModelFieldNumberFormValues>();
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
return (
<Controller
name="settings"
@@ -1,59 +1,47 @@
import styled from '@emotion/styled';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldNumberForm } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from 'twenty-shared/types';
type SettingsDataModelFieldNumberSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'isCustom' | 'settings' | 'isUnique'
> &
Partial<{ id: string }>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldNumberSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldNumberSettingsFormCardProps) => {
const { watch } = useFormContext();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
icon: watch('icon'),
label: watch('label') || 'New Field',
settings: watch('settings') || null,
type: fieldMetadataItem.type,
type: FieldMetadataType.NUMBER,
}}
objectMetadataItem={objectMetadataItem}
objectNameSingular={objectNameSingular}
/>
}
form={
<>
<SettingsDataModelFieldNumberForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
fieldType={FieldMetadataType.NUMBER}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
</>
}
@@ -1,6 +1,6 @@
import { Controller, useFormContext } from 'react-hook-form';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/ui/types/guards/isFieldPhonesValue';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { countryCodeToCallingCode } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue';
@@ -20,17 +20,14 @@ import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFro
type SettingsDataModelFieldPhonesFormProps = {
disabled?: boolean;
defaultCountryCode?: string;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
existingFieldMetadataId: string;
};
export const settingsDataModelFieldPhonesFormSchema = z.object({
defaultValue: phonesFieldDefaultValueSchema,
});
export type SettingsDataModelFieldTextFormValues = z.infer<
export type SettingsDataModelFieldPhonesFormValues = z.infer<
typeof settingsDataModelFieldPhonesFormSchema
>;
@@ -38,10 +35,14 @@ export type CountryCodeOrEmpty = CountryCode | '';
export const SettingsDataModelFieldPhonesForm = ({
disabled,
fieldMetadataItem,
existingFieldMetadataId,
}: SettingsDataModelFieldPhonesFormProps) => {
const { t } = useLingui();
const { control } = useFormContext<SettingsDataModelFieldTextFormValues>();
const { control } = useFormContext<SettingsDataModelFieldPhonesFormValues>();
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
const countries = [
{ label: t`No country`, value: '', Icon: IconCircleOff },
@@ -1,51 +1,53 @@
import styled from '@emotion/styled';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldPhonesForm } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
SettingsDataModelFieldPhonesForm,
type SettingsDataModelFieldPhonesFormValues,
} from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from 'twenty-shared/types';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldPhonesSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'isCustom' | 'settings'
> &
Partial<{ id: string }>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldPhonesSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldPhonesSettingsFormCardProps) => {
const { watch } = useFormContext<
SettingsDataModelFieldPhonesFormValues &
SettingsDataModelFieldEditFormValues
>();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
type: FieldMetadataType.PHONES,
label: watch('label'),
icon: watch('icon'),
}}
objectNameSingular={objectNameSingular}
/>
}
form={
<>
<SettingsDataModelFieldPhonesForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
fieldType={FieldMetadataType.PHONES}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
</>
}
@@ -3,7 +3,6 @@ import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema';
@@ -46,8 +45,8 @@ export type SettingsDataModelFieldRelationFormValues = z.infer<
>;
type SettingsDataModelFieldRelationFormProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'type'>;
objectMetadataItem: ObjectMetadataItem;
existingFieldMetadataId: string;
objectMetadataItem?: ObjectMetadataItem;
};
export const StyledContainer = styled.div`
@@ -83,7 +82,7 @@ export const RELATION_TYPE_OPTIONS = Object.entries(RELATION_TYPES).map(
);
export const SettingsDataModelFieldRelationForm = ({
fieldMetadataItem,
existingFieldMetadataId,
objectMetadataItem,
}: SettingsDataModelFieldRelationFormProps) => {
const { t } = useLingui();
@@ -100,7 +99,7 @@ export const SettingsDataModelFieldRelationForm = ({
initialRelationObjectMetadataItem,
initialRelationType,
} = useRelationSettingsFormInitialValues({
fieldMetadataItem,
existingFieldMetadataId,
objectMetadataItem,
});
@@ -1,8 +1,6 @@
import styled from '@emotion/styled';
import { useFormContext } from 'react-hook-form';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { RELATION_TYPES } from '@/settings/data-model/constants/RelationTypes';
import {
@@ -11,58 +9,35 @@ import {
} 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 { useRelationSettingsFormInitialValues } from '@/settings/data-model/fields/forms/relation/hooks/useRelationSettingsFormInitialValues';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelRelationFieldPreviewSubWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreviewSubWidget';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import {
FieldMetadataType,
type Relation,
RelationType,
} from '~/generated-metadata/graphql';
type SettingsDataModelFieldRelationSettingsFormCardProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'icon' | 'label' | 'type'> &
Partial<Omit<FieldMetadataItem, 'icon' | 'label' | 'type'>>;
relationFieldMetadataItem?: FieldMetadataItem;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
type SettingsDataModelFieldRelationSettingsFormCardProps = {
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldRelationSettingsFormCard = ({
fieldMetadataItem,
objectMetadataItem,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldRelationSettingsFormCardProps) => {
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldRelationFormValues>();
const { findObjectMetadataItemById } = useFilteredObjectMetadataItems();
const { watch } = useFormContext<
SettingsDataModelFieldRelationFormValues &
SettingsDataModelFieldEditFormValues
>();
const isMobile = useIsMobile();
const {
initialRelationObjectMetadataItem,
initialRelationType,
initialRelationFieldMetadataItem,
} = useRelationSettingsFormInitialValues({
fieldMetadataItem,
objectMetadataItem,
});
const relationObjectMetadataId = watchFormValue(
'relation.objectMetadataId',
initialRelationObjectMetadataItem?.id,
);
const relationObjectMetadataItem = findObjectMetadataItemById(
relationObjectMetadataId,
);
const { objectMetadataItems } = useObjectMetadataItems();
if (!relationObjectMetadataItem) return null;
const relationType: RelationType = watchFormValue(
'relation.type',
initialRelationType,
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 =
@@ -74,56 +49,50 @@ export const SettingsDataModelFieldRelationSettingsFormCard = ({
<SettingsDataModelPreviewFormCard
preview={
<SettingsDataModelFieldRelationPreviewContent isMobile={isMobile}>
<StyledFieldPreviewCard
<SettingsDataModelRelationFieldPreviewSubWidget
fieldMetadataItem={{
...fieldMetadataItem,
relation: {
type: relationType,
} as Relation,
icon: watch('icon'),
label: watch('label'),
type: FieldMetadataType.RELATION,
settings: {
relationType: relationType,
},
}}
shrink
objectMetadataItem={objectMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
pluralizeLabel={
watchFormValue('relation.type') === RelationType.MANY_TO_ONE
objectNameSingulars={[objectNameSingular]}
fieldPreviewTargetObjectNameSingular={
relationObjectMetadataItem?.nameSingular ?? 'company'
}
pluralizeLabel={relationType === RelationType.ONE_TO_MANY}
/>
<SettingsDataModelRelationPreviewImage
src={relationTypeConfig.imageSrc}
flip={relationTypeConfig.isImageFlipped}
alt={relationTypeConfig.label}
src={relationTypeConfig?.imageSrc}
flip={relationTypeConfig?.isImageFlipped}
alt={relationTypeConfig?.label}
isMobile={isMobile}
/>
<StyledFieldPreviewCard
<SettingsDataModelRelationFieldPreviewSubWidget
fieldMetadataItem={{
...initialRelationFieldMetadataItem,
icon: watchFormValue(
'relation.field.icon',
initialRelationFieldMetadataItem.icon,
),
label:
watchFormValue(
'relation.field.label',
initialRelationFieldMetadataItem.label,
) || 'Field name',
icon: relationTargetField?.icon,
label: relationTargetField?.label,
type: FieldMetadataType.RELATION,
relation: {
type: oppositeRelationType,
} as Relation,
settings: {
relationType: oppositeRelationType,
},
}}
shrink
objectMetadataItem={relationObjectMetadataItem}
relationObjectMetadataItem={objectMetadataItem}
pluralizeLabel={
watchFormValue('relation.type') !== RelationType.MANY_TO_ONE
}
objectNameSingulars={[
relationObjectMetadataItem?.nameSingular ?? 'company',
]}
fieldPreviewTargetObjectNameSingular={objectNameSingular}
pluralizeLabel={oppositeRelationType === RelationType.ONE_TO_MANY}
/>
</SettingsDataModelFieldRelationPreviewContent>
}
form={
<SettingsDataModelFieldRelationForm
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
existingFieldMetadataId={existingFieldMetadataId}
objectMetadataItem={relationObjectMetadataItem}
/>
}
/>
@@ -1,22 +1,29 @@
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 FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { type SettingsDataModelFieldPreviewCardProps } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
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 = ({
fieldMetadataItem,
existingFieldMetadataId,
objectMetadataItem,
}: {
fieldMetadataItem?: Pick<FieldMetadataItem, 'type' | 'relation'>;
objectMetadataItem?: SettingsDataModelFieldPreviewCardProps['objectMetadataItem'];
}) => {
}: UseRelationSettingsFormInitialValuesProps) => {
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
const getRelationMetadata = useGetRelationMetadata();
const {
@@ -52,14 +59,10 @@ export const useRelationSettingsFormInitialValues = ({
const initialRelationType =
relationTypeFromFieldMetadata ?? RelationType.ONE_TO_MANY;
const targetIsMorphRelation =
isDefined(relationFieldMetadataItem) &&
isFieldMorphRelation(relationFieldMetadataItem);
return {
disableFieldEdition:
relationFieldMetadataItem &&
(!relationFieldMetadataItem.isCustom || targetIsMorphRelation),
isDefined(relationFieldMetadataItem) &&
relationFieldMetadataItem?.isCustom === true,
disableRelationEdition: !!relationFieldMetadataItem,
initialRelationFieldMetadataItem: relationFieldMetadataItem ?? {
icon: initialRelationObjectMetadataItem.icon ?? 'IconUsers',
@@ -3,10 +3,7 @@ import { type DropResult } from '@hello-pangea/dnd';
import { Controller, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import {
type FieldMetadataItem,
type FieldMetadataItemOption,
} from '@/object-metadata/types/FieldMetadataItem';
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { selectOptionsSchema } from '@/object-metadata/validation-schemas/selectOptionsSchema';
import { multiSelectFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/multiSelectFieldDefaultValueSchema';
import { selectFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/selectFieldDefaultValueSchema';
@@ -46,10 +43,8 @@ export type SettingsDataModelFieldSelectFormValues = z.infer<
>;
type SettingsDataModelFieldSelectFormProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
fieldType: FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT;
existingFieldMetadataId: string;
};
const StyledContainer = styled(CardContent)`
@@ -112,10 +107,13 @@ const StyledButton = styled(LightButton)`
`;
export const SettingsDataModelFieldSelectForm = ({
fieldMetadataItem,
existingFieldMetadataId,
fieldType,
}: SettingsDataModelFieldSelectFormProps) => {
const { initialDefaultValue, initialOptions } =
useSelectSettingsFormInitialValues({ fieldMetadataItem });
useSelectSettingsFormInitialValues({
fieldMetadataId: existingFieldMetadataId,
});
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
const {
@@ -144,7 +142,7 @@ export const SettingsDataModelFieldSelectForm = ({
optionValue: FieldMetadataItemOption['value'],
) =>
isSelectOptionDefaultValue(optionValue, {
type: fieldMetadataItem.type,
type: fieldType,
defaultValue: watchFormValue('defaultValue'),
});
@@ -153,7 +151,7 @@ export const SettingsDataModelFieldSelectForm = ({
) => {
if (isOptionDefaultValue(optionValue)) return;
if (fieldMetadataItem.type === FieldMetadataType.SELECT) {
if (fieldType === FieldMetadataType.SELECT) {
setFormValue('defaultValue', applySimpleQuotesToString(optionValue), {
shouldDirty: true,
});
@@ -163,7 +161,7 @@ export const SettingsDataModelFieldSelectForm = ({
const previousDefaultValue = getValues('defaultValue');
if (
fieldMetadataItem.type === FieldMetadataType.MULTI_SELECT &&
fieldType === FieldMetadataType.MULTI_SELECT &&
(Array.isArray(previousDefaultValue) || previousDefaultValue === null)
) {
setFormValue(
@@ -182,7 +180,7 @@ export const SettingsDataModelFieldSelectForm = ({
) => {
if (!isOptionDefaultValue(optionValue)) return;
if (fieldMetadataItem.type === FieldMetadataType.SELECT) {
if (fieldType === FieldMetadataType.SELECT) {
setFormValue('defaultValue', null, { shouldDirty: true });
return;
}
@@ -190,7 +188,7 @@ export const SettingsDataModelFieldSelectForm = ({
const previousDefaultValue = getValues('defaultValue');
if (
fieldMetadataItem.type === FieldMetadataType.MULTI_SELECT &&
fieldType === FieldMetadataType.MULTI_SELECT &&
(Array.isArray(previousDefaultValue) || previousDefaultValue === null)
) {
const nextDefaultValue = previousDefaultValue?.filter(
@@ -1,19 +1,15 @@
import styled from '@emotion/styled';
import { useFormContext } from 'react-hook-form';
import { type z } from 'zod';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
type settingsDataModelFieldMultiSelectFormSchema,
SettingsDataModelFieldSelectForm,
type settingsDataModelFieldSelectFormSchema,
} from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm';
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/select/hooks/useSelectSettingsFormInitialValues';
import {
SettingsDataModelFieldPreviewCard,
type SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { type FieldMetadataType } from 'twenty-shared/types';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldSelectOrMultiSelectFormValues = z.infer<
| typeof settingsDataModelFieldSelectFormSchema
@@ -21,53 +17,40 @@ type SettingsDataModelFieldSelectOrMultiSelectFormValues = z.infer<
>;
type SettingsDataModelFieldSelectSettingsFormCardProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
| 'icon'
| 'label'
| 'type'
| 'defaultValue'
| 'options'
| 'isUnique'
| 'isCustom'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
display: grid;
flex: 1 1 100%;
`;
objectNameSingular: string;
fieldType: FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT;
existingFieldMetadataId: string;
};
export const SettingsDataModelFieldSelectSettingsFormCard = ({
fieldMetadataItem,
objectMetadataItem,
objectNameSingular,
fieldType,
existingFieldMetadataId,
}: SettingsDataModelFieldSelectSettingsFormCardProps) => {
const { initialOptions, initialDefaultValue } =
useSelectSettingsFormInitialValues({
fieldMetadataItem,
});
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldSelectOrMultiSelectFormValues>();
const { watch: watchFormValue } = useFormContext<
SettingsDataModelFieldSelectOrMultiSelectFormValues &
SettingsDataModelFieldEditFormValues
>();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
...fieldMetadataItem,
defaultValue: watchFormValue('defaultValue', initialDefaultValue),
options: watchFormValue('options', initialOptions),
type: fieldType,
label: watchFormValue('label'),
icon: watchFormValue('icon'),
defaultValue: watchFormValue('defaultValue'),
options: watchFormValue('options'),
}}
objectMetadataItem={objectMetadataItem}
objectNameSingular={objectNameSingular}
/>
}
form={
<>
<SettingsDataModelFieldSelectForm
fieldMetadataItem={fieldMetadataItem}
/>
</>
<SettingsDataModelFieldSelectForm
fieldType={fieldType}
existingFieldMetadataId={existingFieldMetadataId}
/>
}
/>
);
@@ -2,10 +2,8 @@ import { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { v4 } from 'uuid';
import {
type FieldMetadataItem,
type FieldMetadataItemOption,
} from '@/object-metadata/types/FieldMetadataItem';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { type SettingsDataModelFieldSelectFormValues } from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm';
import { computeOptionValueFromLabel } from '~/pages/settings/data-model/utils/compute-option-value-from-label.utils';
@@ -17,11 +15,15 @@ const DEFAULT_OPTION: FieldMetadataItemOption = {
value: computeOptionValueFromLabel('Option 1'),
};
type UseSelectSettingsFormInitialValuesProps = {
fieldMetadataId: string;
};
export const useSelectSettingsFormInitialValues = ({
fieldMetadataItem,
}: {
fieldMetadataItem?: Pick<FieldMetadataItem, 'defaultValue' | 'options'>;
}) => {
fieldMetadataId,
}: UseSelectSettingsFormInitialValuesProps) => {
const { fieldMetadataItem } = useFieldMetadataItemById(fieldMetadataId);
const initialDefaultValue =
(fieldMetadataItem?.defaultValue as SettingsDataModelFieldSelectFormValues['defaultValue']) ??
null;
@@ -1,38 +1,24 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { BooleanFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/BooleanFieldInput';
import { RatingFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RatingFieldInput';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect';
import { SettingsDataModelSetPreviewRecordEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetRecordEffect';
import { useFieldPreviewValue } from '@/settings/data-model/fields/preview/hooks/useFieldPreviewValue';
import { usePreviewRecord } from '@/settings/data-model/fields/preview/hooks/usePreviewRecord';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type SettingsDataModelFieldPreviewProps = {
type SettingsDataModelFieldPreviewProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
| 'icon'
| 'label'
| 'type'
| 'defaultValue'
| 'options'
| 'settings'
| 'relation'
> & {
id?: string;
name?: string;
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
'name' | 'icon' | 'label' | 'type' | 'defaultValue' | 'options' | 'settings'
>;
objectNameSingular: string;
shrink?: boolean;
withFieldLabel?: boolean;
};
@@ -64,54 +50,35 @@ const StyledFieldLabel = styled.div`
export const SettingsDataModelFieldPreview = ({
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
objectNameSingular,
shrink,
withFieldLabel = true,
}: SettingsDataModelFieldPreviewProps) => {
const theme = useTheme();
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular,
});
const { getIcon } = useIcons();
const FieldIcon = getIcon(fieldMetadataItem.icon);
// id and name are undefined in create mode (field does not exist yet)
// and defined in edit mode.
const isLabelIdentifier =
!!fieldMetadataItem.id &&
!!fieldMetadataItem.name &&
isLabelIdentifierField({
fieldMetadataItem: {
id: fieldMetadataItem.id,
name: fieldMetadataItem.name,
},
objectMetadataItem,
});
labelIdentifierFieldMetadataItem?.name === fieldMetadataItem.name;
const previewRecord = usePreviewRecord({
objectMetadataItem,
skip: !isLabelIdentifier,
});
const fieldName = fieldMetadataItem.name;
const recordId = `${objectNameSingular}-${fieldName}-preview`;
const fieldPreviewValue = useFieldPreviewValue({
fieldMetadataItem,
relationObjectMetadataItem,
skip: isLabelIdentifier,
});
const fieldName =
fieldMetadataItem.name || `${fieldMetadataItem.type}-new-field`;
const recordId =
previewRecord?.id ??
`${objectMetadataItem.nameSingular}-${fieldName}-${fieldMetadataItem.relation?.type}-${relationObjectMetadataItem?.nameSingular}-preview`;
const metadata = {
fieldName,
objectMetadataNameSingular: objectMetadataItem.nameSingular,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular || '',
objectMetadataNameSingular: objectNameSingular,
options: fieldMetadataItem.options ?? [],
settings: fieldMetadataItem.settings,
relationType: fieldMetadataItem.relation?.type,
};
return (
@@ -121,15 +88,11 @@ export const SettingsDataModelFieldPreview = ({
instanceId: 'record-field-component-instance-id',
}}
>
{isDefined(previewRecord) ? (
<SettingsDataModelSetPreviewRecordEffect record={previewRecord} />
) : (
<SettingsDataModelSetFieldValueEffect
recordId={recordId}
fieldName={fieldName}
value={fieldPreviewValue}
/>
)}
<SettingsDataModelSetFieldValueEffect
recordId={recordId}
gqlFieldName={fieldMetadataItem.name ?? ''}
value={fieldPreviewValue}
/>
<StyledFieldPreview shrink={shrink}>
{!!withFieldLabel && (
<StyledFieldLabel>
@@ -147,7 +110,7 @@ export const SettingsDataModelFieldPreview = ({
fieldDefinition: {
type: fieldMetadataItem.type,
iconName: 'FieldIcon',
fieldMetadataId: fieldMetadataItem.id || '',
fieldMetadataId: '',
label: fieldMetadataItem.label,
metadata,
defaultValue: fieldMetadataItem.defaultValue,
@@ -1,51 +0,0 @@
import styled from '@emotion/styled';
import {
SettingsDataModelFieldPreview,
type SettingsDataModelFieldPreviewProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview';
import { SettingsDataModelObjectPreview } from '@/settings/data-model/objects/components/SettingsDataModelObjectSummary';
import { Card, CardContent } from 'twenty-ui/layout';
export type SettingsDataModelFieldPreviewCardProps =
SettingsDataModelFieldPreviewProps & {
className?: string;
pluralizeLabel?: boolean;
};
const StyledCard = styled(Card)`
border-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledCardContent = styled(CardContent)`
padding: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsDataModelFieldPreviewCard = ({
className,
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
shrink,
withFieldLabel = true,
pluralizeLabel = false,
}: SettingsDataModelFieldPreviewCardProps) => {
return (
<StyledCard className={className} fullWidth>
<StyledCardContent>
<SettingsDataModelObjectPreview
objectMetadataItems={[objectMetadataItem]}
pluralizeLabel={pluralizeLabel}
/>
<SettingsDataModelFieldPreview
objectMetadataItem={objectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
shrink={shrink}
withFieldLabel={withFieldLabel}
/>
</StyledCardContent>
</StyledCard>
);
};
@@ -0,0 +1,68 @@
import styled from '@emotion/styled';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelFieldPreview } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview';
import { SettingsDataModelObjectPreview } from '@/settings/data-model/objects/components/SettingsDataModelObjectSummary';
import { Card, CardContent } from 'twenty-ui/layout';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils';
type SettingsDataModelFieldPreviewWidgetProps = {
className?: string;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'options' | 'settings'
>;
objectNameSingular: string;
shrink?: boolean;
withFieldLabel?: boolean;
pluralizeLabel?: boolean;
fullWidth?: boolean;
};
const StyledCard = styled(Card)`
border-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledCardContent = styled(CardContent)`
padding: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsDataModelFieldPreviewWidget = ({
className,
fieldMetadataItem,
objectNameSingular,
shrink,
withFieldLabel = true,
pluralizeLabel = false,
}: SettingsDataModelFieldPreviewWidgetProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
return (
<StyledCard className={className} fullWidth>
<StyledCardContent>
<SettingsDataModelObjectPreview
objectMetadataItems={[objectMetadataItem]}
pluralizeLabel={pluralizeLabel}
/>
<SettingsDataModelFieldPreview
objectNameSingular={objectMetadataItem.nameSingular}
fieldMetadataItem={{
label: fieldMetadataItem.label,
icon: fieldMetadataItem.icon,
defaultValue: fieldMetadataItem.defaultValue,
options: fieldMetadataItem.options,
settings: fieldMetadataItem.settings,
type: fieldMetadataItem.type,
name: computeMetadataNameFromLabel(fieldMetadataItem.label),
}}
shrink={shrink}
withFieldLabel={withFieldLabel}
/>
</StyledCardContent>
</StyledCard>
);
};
@@ -1,86 +0,0 @@
import styled from '@emotion/styled';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { SettingsDataModelFieldPreview } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview';
import { SettingsDataModelObjectPreview } from '@/settings/data-model/objects/components/SettingsDataModelObjectSummary';
import { Card, CardContent } from 'twenty-ui/layout';
export type SettingsDataModelMorphRelationFieldPreviewCardProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
| 'icon'
| 'label'
| 'type'
| 'defaultValue'
| 'options'
| 'settings'
| 'relation'
> & {
id?: string;
name?: string;
morphRelations: {
targetObjectMetadata: Pick<
ObjectMetadataItem,
| 'id'
| 'nameSingular'
| 'labelSingular'
| 'labelPlural'
| 'icon'
| 'isCustom'
| 'isRemote'
>;
}[];
};
objectMetadataItem: ObjectMetadataItem;
relationObjectMetadataItem?: ObjectMetadataItem;
shrink?: boolean;
withFieldLabel?: boolean;
className?: string;
pluralizeLabel?: boolean;
};
const StyledCard = styled(Card)`
border-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledCardContent = styled(CardContent)`
padding: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsDataModelMorphRelationFieldPreviewCard = ({
className,
fieldMetadataItem,
objectMetadataItem,
relationObjectMetadataItem,
shrink,
withFieldLabel = true,
pluralizeLabel = false,
}: SettingsDataModelMorphRelationFieldPreviewCardProps) => {
const objectMetadataItems = fieldMetadataItem.morphRelations?.map(
(morphRelation) => morphRelation.targetObjectMetadata,
);
if (!objectMetadataItems || objectMetadataItems.length === 0) {
return null;
}
return (
<StyledCard className={className} fullWidth>
<StyledCardContent>
<SettingsDataModelObjectPreview
objectMetadataItems={objectMetadataItems}
pluralizeLabel={pluralizeLabel}
/>
<SettingsDataModelFieldPreview
objectMetadataItem={objectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
relationObjectMetadataItem={relationObjectMetadataItem}
shrink={shrink}
withFieldLabel={withFieldLabel}
/>
</StyledCardContent>
</StyledCard>
);
};
@@ -0,0 +1,165 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { SettingsDataModelSetFieldValueEffect } from '@/settings/data-model/fields/preview/components/SettingsDataModelSetFieldValueEffect';
import { useFieldPreviewValue } from '@/settings/data-model/fields/preview/hooks/useFieldPreviewValue';
import { computeMorphRelationFieldName } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { v4 } from 'uuid';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type SettingsDataModelRelationFieldPreviewProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'settings'
>;
relationTargetObjectNameSingular: string;
shrink?: boolean;
withFieldLabel?: boolean;
};
const StyledFieldPreview = styled.div<{ shrink?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: fit-content;
line-height: 24px;
overflow: hidden;
padding: 0
${({ shrink, theme }) => (shrink ? theme.spacing(1) : theme.spacing(2))};
white-space: nowrap;
margin-top: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledFieldLabel = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const SettingsDataModelRelationFieldPreview = ({
fieldMetadataItem,
relationTargetObjectNameSingular,
shrink,
withFieldLabel = true,
}: SettingsDataModelRelationFieldPreviewProps) => {
const { objectMetadataItem: relationTargetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: relationTargetObjectNameSingular,
});
const theme = useTheme();
const { getIcon } = useIcons();
const FieldIcon = getIcon(fieldMetadataItem.icon);
const fieldPreviewValue = useFieldPreviewValue({
fieldMetadataItem,
relationObjectNameSingular: relationTargetObjectNameSingular,
});
const fieldName = v4();
const recordId = `${relationTargetObjectNameSingular}-${fieldName}-preview`;
const metadata = {
fieldName,
objectMetadataNameSingular: 'company',
relationObjectMetadataNameSingular: relationTargetObjectNameSingular,
options: [],
settings: fieldMetadataItem.settings,
relationType: fieldMetadataItem.settings?.relationType,
morphRelations:
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION
? [
{
type: fieldMetadataItem.settings?.relationType,
sourceFieldMetadata: {
id: v4(),
name: fieldName,
},
targetFieldMetadata: {
id: v4(),
name: 'does-not-matter',
},
sourceObjectMetadata: {
id: v4(),
namePlural: 'does-not-matter',
nameSingular: 'does-not-matter',
},
targetObjectMetadata: {
id: v4(),
namePlural: relationTargetObjectMetadataItem.namePlural,
nameSingular: relationTargetObjectMetadataItem.nameSingular,
},
} satisfies FieldMetadataItemRelation,
]
: [],
};
return (
<>
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: 'record-field-component-instance-id',
}}
>
<SettingsDataModelSetFieldValueEffect
recordId={recordId}
gqlFieldName={
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION
? computeMorphRelationFieldName({
fieldName: fieldName ?? '',
relationDirection: fieldMetadataItem.settings?.relationType,
nameSingular: relationTargetObjectNameSingular,
namePlural: relationTargetObjectMetadataItem.namePlural,
})
: fieldName
}
value={fieldPreviewValue}
/>
<StyledFieldPreview shrink={shrink}>
{!!withFieldLabel && (
<StyledFieldLabel>
<FieldIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
{fieldMetadataItem.label}:
</StyledFieldLabel>
)}
<FieldContext.Provider
value={{
recordId,
isLabelIdentifier: false,
fieldDefinition: {
type: fieldMetadataItem.type,
iconName: 'FieldIcon',
fieldMetadataId: '',
label: fieldMetadataItem.label,
metadata,
defaultValue: null,
},
isRecordFieldReadOnly: true,
disableChipClick: true,
}}
>
<FieldDisplay />
</FieldContext.Provider>
</StyledFieldPreview>
</RecordFieldComponentInstanceContext.Provider>
</>
);
};
@@ -0,0 +1,67 @@
import styled from '@emotion/styled';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelRelationFieldPreview } from '@/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreview';
import { SettingsDataModelObjectPreview } from '@/settings/data-model/objects/components/SettingsDataModelObjectSummary';
import { isDefined } from 'twenty-shared/utils';
import { Card, CardContent } from 'twenty-ui/layout';
export type SettingsDataModelRelationFieldPreviewSubWidgetProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'settings'
>;
objectNameSingulars: string[];
fieldPreviewTargetObjectNameSingular: string;
shrink?: boolean;
withFieldLabel?: boolean;
className?: string;
pluralizeLabel?: boolean;
};
const StyledCard = styled(Card)`
border-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledCardContent = styled(CardContent)`
padding: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsDataModelRelationFieldPreviewSubWidget = ({
className,
fieldMetadataItem,
objectNameSingulars,
fieldPreviewTargetObjectNameSingular,
shrink,
withFieldLabel = true,
pluralizeLabel = false,
}: SettingsDataModelRelationFieldPreviewSubWidgetProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const targetObjectMetadataItems = objectNameSingulars
.map((nameSingular) =>
objectMetadataItems.find((item) => item.nameSingular === nameSingular),
)
.filter(isDefined);
return (
<StyledCard className={className} fullWidth>
<StyledCardContent>
<SettingsDataModelObjectPreview
objectMetadataItems={targetObjectMetadataItems}
pluralizeLabel={pluralizeLabel}
/>
<SettingsDataModelRelationFieldPreview
fieldMetadataItem={fieldMetadataItem}
relationTargetObjectNameSingular={
fieldPreviewTargetObjectNameSingular
}
shrink={shrink}
withFieldLabel={withFieldLabel}
/>
</StyledCardContent>
</StyledCard>
);
};
@@ -1,44 +1,28 @@
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { settingsPreviewRecordIdState } from '@/settings/data-model/fields/preview/states/settingsPreviewRecordIdState';
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useSetRecoilState } from 'recoil';
type SettingsDataModelSetFieldValueEffectProps = {
recordId: string;
fieldName: string;
gqlFieldName: string;
value: unknown;
};
export const SettingsDataModelSetFieldValueEffect = ({
recordId,
fieldName,
gqlFieldName,
value,
}: SettingsDataModelSetFieldValueEffectProps) => {
const settingsPreviewRecordId = useRecoilValue(settingsPreviewRecordIdState);
const upsertedPreviewRecord = useRecoilValue(
recordStoreFamilyState(settingsPreviewRecordId ?? ''),
);
const setFieldValue = useSetRecoilState(
recordStoreFamilySelector({
recordId,
fieldName,
fieldName: gqlFieldName,
}),
);
useEffect(() => {
if (
isDefined(upsertedPreviewRecord) &&
!!upsertedPreviewRecord[fieldName]
) {
setFieldValue(upsertedPreviewRecord[fieldName]);
} else {
setFieldValue(value);
}
}, [value, setFieldValue, recordId, fieldName, upsertedPreviewRecord]);
setFieldValue(value);
}, [value, setFieldValue, recordId, gqlFieldName]);
return null;
};
@@ -1,26 +0,0 @@
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { settingsPreviewRecordIdState } from '@/settings/data-model/fields/preview/states/settingsPreviewRecordIdState';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
type SettingsDataModelSetPreviewRecordEffectProps = {
record: ObjectRecord;
};
export const SettingsDataModelSetPreviewRecordEffect = ({
record,
}: SettingsDataModelSetPreviewRecordEffectProps) => {
const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore();
const setSettingsPreviewRecordId = useSetRecoilState(
settingsPreviewRecordIdState,
);
useEffect(() => {
upsertRecordsInStore([record]);
setSettingsPreviewRecordId(record.id);
}, [record, upsertRecordsInStore, setSettingsPreviewRecordId]);
return null;
};
@@ -9,7 +9,7 @@ import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadat
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
import { SettingsDataModelFieldPreviewCard } from '../SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '../SettingsDataModelFieldPreviewWidget';
const mockedCompanyObjectMetadataItem =
getMockObjectMetadataItemOrThrow('company');
@@ -20,10 +20,10 @@ const mockedOpportunityObjectMetadataItem =
const mockedPersonObjectMetadataItem =
getMockObjectMetadataItemOrThrow('person');
const meta: Meta<typeof SettingsDataModelFieldPreviewCard> = {
const meta: Meta<typeof SettingsDataModelFieldPreviewWidget> = {
title:
'Modules/Settings/DataModel/Fields/Preview/SettingsDataModelFieldPreviewCard',
component: SettingsDataModelFieldPreviewCard,
'Modules/Settings/DataModel/Fields/Preview/SettingsDataModelFieldPreviewWidget',
component: SettingsDataModelFieldPreviewWidget,
decorators: [
MemoryRouterDecorator,
ComponentDecorator,
@@ -31,7 +31,7 @@ const meta: Meta<typeof SettingsDataModelFieldPreviewCard> = {
SnackBarDecorator,
],
args: {
objectMetadataItem: mockedPersonObjectMetadataItem,
objectNameSingular: mockedPersonObjectMetadataItem.nameSingular,
},
parameters: {
container: { width: 480 },
@@ -40,7 +40,7 @@ const meta: Meta<typeof SettingsDataModelFieldPreviewCard> = {
};
export default meta;
type Story = StoryObj<typeof SettingsDataModelFieldPreviewCard>;
type Story = StoryObj<typeof SettingsDataModelFieldPreviewWidget>;
export const LabelIdentifier: Story = {
args: {
@@ -65,7 +65,7 @@ export const Boolean: Story = {
({ name, type }) =>
name === 'idealCustomerProfile' && type === FieldMetadataType.BOOLEAN,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
objectNameSingular: mockedCompanyObjectMetadataItem.nameSingular,
},
};
@@ -76,7 +76,7 @@ export const Currency: Story = {
name === 'annualRecurringRevenue' &&
type === FieldMetadataType.CURRENCY,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
objectNameSingular: mockedCompanyObjectMetadataItem.nameSingular,
},
};
@@ -85,7 +85,7 @@ export const Date: Story = {
fieldMetadataItem: mockedCompanyObjectMetadataItem.fields.find(
({ type }) => type === FieldMetadataType.DATE_TIME,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
objectNameSingular: mockedCompanyObjectMetadataItem.nameSingular,
},
};
@@ -95,7 +95,7 @@ export const Links: Story = {
({ name, type }) =>
name === 'linkedinLink' && type === FieldMetadataType.LINKS,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
objectNameSingular: mockedCompanyObjectMetadataItem.nameSingular,
},
};
@@ -104,7 +104,7 @@ export const Number: Story = {
fieldMetadataItem: mockedCompanyObjectMetadataItem?.fields.find(
({ type }) => type === FieldMetadataType.NUMBER,
),
objectMetadataItem: mockedCompanyObjectMetadataItem,
objectNameSingular: mockedCompanyObjectMetadataItem.nameSingular,
},
};
@@ -123,7 +123,7 @@ export const Relation: Story = {
fieldMetadataItem: mockedPersonObjectMetadataItem?.fields.find(
({ name }) => name === 'company',
),
relationObjectMetadataItem: mockedCompanyObjectMetadataItem,
objectNameSingular: mockedPersonObjectMetadataItem.nameSingular,
},
};
@@ -135,13 +135,13 @@ const selectFieldMetadataItem = getMockFieldMetadataItemOrThrow({
export const Select: Story = {
args: {
fieldMetadataItem: selectFieldMetadataItem,
objectMetadataItem: mockedOpportunityObjectMetadataItem,
objectNameSingular: mockedOpportunityObjectMetadataItem.nameSingular,
},
};
export const MultiSelect: Story = {
args: {
objectMetadataItem: mockedOpportunityObjectMetadataItem,
objectNameSingular: mockedOpportunityObjectMetadataItem.nameSingular,
fieldMetadataItem: {
...structuredClone(selectFieldMetadataItem),
type: FieldMetadataType.MULTI_SELECT,
@@ -86,7 +86,7 @@ describe('useFieldPreviewValue', () => {
() =>
useFieldPreviewValue({
fieldMetadataItem,
relationObjectMetadataItem,
relationObjectNameSingular: relationObjectMetadataItem?.nameSingular,
}),
{ wrapper: Wrapper },
);
@@ -1,6 +1,5 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRelationFieldPreviewValue } from '@/settings/data-model/fields/preview/hooks/useRelationFieldPreviewValue';
import { getAddressFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue';
import { getCurrencyFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue';
@@ -8,33 +7,31 @@ import { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils
import { getMultiSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getMultiSelectFieldPreviewValue';
import { getPhonesFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue';
import { getSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getSelectFieldPreviewValue';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
type UseFieldPreviewParams = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'type' | 'options' | 'defaultValue' | 'relation'
'type' | 'options' | 'defaultValue' | 'settings'
>;
relationObjectMetadataItem?: ObjectMetadataItem;
relationObjectNameSingular?: string;
skip?: boolean;
};
export const useFieldPreviewValue = ({
fieldMetadataItem,
relationObjectMetadataItem,
relationObjectNameSingular,
skip,
}: UseFieldPreviewParams) => {
const relationFieldPreviewValue = useRelationFieldPreviewValue({
relationObjectMetadataItem: relationObjectMetadataItem ?? {
fields: [],
labelSingular: '',
labelIdentifierFieldMetadataId: '20202020-1000-4629-87e5-9a1fae1cc2fd',
nameSingular: CoreObjectNameSingular.Company,
},
relationObjectNameSingular:
relationObjectNameSingular ?? CoreObjectNameSingular.Company,
skip:
skip ||
fieldMetadataItem.type !== FieldMetadataType.RELATION ||
!relationObjectMetadataItem,
(fieldMetadataItem.type !== FieldMetadataType.RELATION &&
fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION) ||
!isDefined(relationObjectNameSingular),
});
if (skip === true) return null;
@@ -42,8 +39,10 @@ export const useFieldPreviewValue = ({
switch (fieldMetadataItem.type) {
case FieldMetadataType.CURRENCY:
return getCurrencyFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.MORPH_RELATION:
case FieldMetadataType.RELATION:
return fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE
return fieldMetadataItem.settings?.relationType ===
RelationType.MANY_TO_ONE
? relationFieldPreviewValue
: [relationFieldPreviewValue];
case FieldMetadataType.SELECT:
@@ -56,7 +55,9 @@ export const useFieldPreviewValue = ({
return getPhonesFieldPreviewValue({ fieldMetadataItem });
default:
return getFieldPreviewValue({
fieldMetadataItem,
fieldType: fieldMetadataItem.type,
fieldSettings: fieldMetadataItem.settings,
defaultValue: fieldMetadataItem.defaultValue,
});
}
};
@@ -1,41 +1,36 @@
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { isFieldValueEmpty } from '@/object-record/record-field/ui/utils/isFieldValueEmpty';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getFieldPreviewValue';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { pascalCase } from '~/utils/string/pascalCase';
type UsePreviewRecordParams = {
objectMetadataItem: Pick<
ObjectMetadataItem,
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'labelSingular'
| 'nameSingular'
>;
objectNameSingular: string;
skip?: boolean;
};
export const usePreviewRecord = ({
objectMetadataItem,
objectNameSingular,
skip: skipFromProps,
}: UsePreviewRecordParams): ObjectRecord | null => {
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular,
});
const skip = skipFromProps || !labelIdentifierFieldMetadataItem;
let recordGqlFields: Record<string, boolean> | undefined = undefined;
if (objectMetadataItem.nameSingular === CoreObjectNameSingular.NoteTarget)
if (objectNameSingular === CoreObjectNameSingular.NoteTarget)
recordGqlFields = { id: true, note: true };
if (objectMetadataItem.nameSingular === CoreObjectNameSingular.TaskTarget)
if (objectNameSingular === CoreObjectNameSingular.TaskTarget)
recordGqlFields = { id: true, task: true };
const { records } = useFindManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
objectNameSingular,
recordGqlFields,
limit: 1,
skip,
@@ -55,15 +50,14 @@ export const usePreviewRecord = ({
return firstRecord;
}
const fieldPreviewValue =
labelIdentifierFieldMetadataItem.type === FieldMetadataType.TEXT
? objectMetadataItem.labelSingular
: getFieldPreviewValue({
fieldMetadataItem: labelIdentifierFieldMetadataItem,
});
const fieldPreviewValue = getFieldPreviewValue({
fieldType: labelIdentifierFieldMetadataItem.type,
fieldSettings: labelIdentifierFieldMetadataItem.settings,
defaultValue: labelIdentifierFieldMetadataItem.defaultValue,
});
const placeholderRecord = {
__typename: pascalCase(objectMetadataItem.nameSingular),
__typename: pascalCase(objectNameSingular),
id: '',
[labelIdentifierFieldMetadataItem.name]: fieldPreviewValue,
};
@@ -1,22 +1,15 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { usePreviewRecord } from '@/settings/data-model/fields/preview/hooks/usePreviewRecord';
type UseRelationFieldPreviewParams = {
relationObjectMetadataItem: Pick<
ObjectMetadataItem,
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'labelSingular'
| 'nameSingular'
>;
relationObjectNameSingular: string;
skip?: boolean;
};
export const useRelationFieldPreviewValue = ({
relationObjectMetadataItem,
relationObjectNameSingular,
skip,
}: UseRelationFieldPreviewParams) =>
usePreviewRecord({
objectMetadataItem: relationObjectMetadataItem,
objectNameSingular: relationObjectNameSingular,
skip,
});
@@ -1,5 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export const settingsPreviewRecordIdState = createState<string | null>({
key: 'settingsPreviewRecordIdState',
defaultValue: null,
});
@@ -25,11 +25,13 @@ describe('getFieldPreviewValue', () => {
// When
const result = getFieldPreviewValue({
fieldMetadataItem,
fieldType: fieldMetadataItem.type,
fieldSettings: fieldMetadataItem.settings,
defaultValue: fieldMetadataItem.defaultValue,
});
// Then
expect(result).toBe(true);
expect(result).toBe(false);
});
it('returns a placeholder defaultValue if the field metadata does not have a defaultValue', () => {
@@ -45,7 +47,9 @@ describe('getFieldPreviewValue', () => {
// When
const result = getFieldPreviewValue({
fieldMetadataItem,
fieldType: fieldMetadataItem.type,
fieldSettings: fieldMetadataItem.settings,
defaultValue: fieldMetadataItem.defaultValue,
});
// Then
@@ -68,7 +72,9 @@ describe('getFieldPreviewValue', () => {
// When
const result = getFieldPreviewValue({
fieldMetadataItem,
fieldType: fieldMetadataItem.type,
fieldSettings: fieldMetadataItem.settings,
defaultValue: fieldMetadataItem.defaultValue,
});
// Then
@@ -1,34 +1,41 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldValueEmpty } from '@/object-record/record-field/ui/utils/isFieldValueEmpty';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { isFieldTypeSupportedInSettings } from '@/settings/data-model/utils/isFieldTypeSupportedInSettings';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { stripSimpleQuotesFromStringRecursive } from '~/utils/string/stripSimpleQuotesFromString';
type getFieldPreviewValueArgs = {
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'defaultValue'>;
fieldType: FieldMetadataType;
fieldSettings: FieldMetadata['settings'];
defaultValue: unknown;
};
export const getFieldPreviewValue = ({
fieldMetadataItem,
fieldType,
fieldSettings,
defaultValue,
}: getFieldPreviewValueArgs) => {
if (!isFieldTypeSupportedInSettings(fieldMetadataItem.type)) return null;
if (!isFieldTypeSupportedInSettings(fieldType)) return null;
if (
!isFieldValueEmpty({
fieldDefinition: { type: fieldMetadataItem.type },
fieldValue: stripSimpleQuotesFromStringRecursive(
fieldMetadataItem.defaultValue,
),
fieldDefinition: { type: fieldType },
fieldValue: stripSimpleQuotesFromStringRecursive(defaultValue),
})
) {
return generateEmptyFieldValue({
fieldMetadataItem,
fieldMetadataItem: {
type: fieldType,
settings: fieldSettings,
defaultValue,
},
shouldComputeFunctionDefaultValue: true,
});
}
const fieldTypeConfig = getSettingsFieldTypeConfig(fieldMetadataItem.type);
const fieldTypeConfig = getSettingsFieldTypeConfig(fieldType);
if (
isDefined(fieldTypeConfig) &&
@@ -138,6 +138,7 @@ export const SettingsDataModelObjectPreview = ({
selected++;
return selected <= 3 ? (
<SettingsDataModelObjectPreviewItem
key={`${objectMetadataItem.labelSingular}-${index}`}
objectMetadataItem={objectMetadataItem}
pluralizeLabel={pluralizeLabel}
index={index}
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { SettingsDataModelCardTitle } from '@/settings/data-model/components/SettingsDataModelCardTitle';
import { SettingsDataModelFieldPreviewCard } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { SettingsDataModelObjectPreview } from '@/settings/data-model/objects/components/SettingsDataModelObjectSummary';
import { SettingsDataModelObjectIdentifiersForm } from '@/settings/data-model/objects/forms/components/SettingsDataModelObjectIdentifiersForm';
import { Trans } from '@lingui/react/macro';
@@ -14,10 +14,6 @@ type SettingsDataModelObjectSettingsFormCardProps = {
objectMetadataItem: ObjectMetadataItem;
};
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
width: 100%;
`;
const StyledTopCardContent = styled(CardContent)`
background-color: ${({ theme }) => theme.background.transparent.lighter};
`;
@@ -50,8 +46,8 @@ export const SettingsDataModelObjectSettingsFormCard = ({
<Trans>Preview</Trans>
</SettingsDataModelCardTitle>
{labelIdentifierFieldMetadataItem ? (
<StyledFieldPreviewCard
objectMetadataItem={objectMetadataItem}
<SettingsDataModelFieldPreviewWidget
objectNameSingular={objectMetadataItem.nameSingular}
fieldMetadataItem={labelIdentifierFieldMetadataItem}
withFieldLabel={false}
/>
@@ -11,6 +11,7 @@ export const getFieldPreviewValueFromRecord = ({
}) => {
const recordFieldValue = record[fieldMetadataItem.name];
// todo @guillim : adjust this to handle morph relations
// Relation fields (to many)
if (
fieldMetadataItem.type === FieldMetadataType.RELATION &&
@@ -37,7 +37,7 @@ import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
//TODO: fix this type
type SettingsDataModelFieldEditFormValues = z.infer<
export type SettingsDataModelFieldEditFormValues = z.infer<
ReturnType<typeof settingsFieldFormSchema>
> &
any;
@@ -91,6 +91,7 @@ export const SettingsObjectFieldEdit = () => {
}, [navigateApp, objectMetadataItem, fieldMetadataItem]);
const { isDirty, isValid, isSubmitting } = formConfig.formState;
const canSave = isDirty && isValid && !isSubmitting;
if (!isDefined(objectMetadataItem) || !isDefined(fieldMetadataItem)) {
@@ -236,8 +237,9 @@ export const SettingsObjectFieldEdit = () => {
/>
)}
<SettingsDataModelFieldSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
fieldType={fieldMetadataItem.type}
existingFieldMetadataId={fieldMetadataItem.id}
objectNameSingular={objectMetadataItem.nameSingular}
/>
</Section>
</>
@@ -10,7 +10,6 @@ import { SettingsDataModelFieldDescriptionForm } from '@/settings/data-model/fie
import { SettingsDataModelFieldIconLabelForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm';
import { SettingsDataModelFieldSettingsFormCard } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard';
import { settingsFieldFormSchema } from '@/settings/data-model/fields/forms/validation-schemas/settingsFieldFormSchema';
import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFieldType';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -48,7 +47,7 @@ export const SettingsObjectNewFieldConfigure = () => {
const { objectNamePlural = '' } = useParams();
const [searchParams] = useSearchParams();
const fieldType =
(searchParams.get('fieldType') as SettingsFieldType) ||
(searchParams.get('fieldType') as FieldMetadataType) ||
FieldMetadataType.TEXT;
const { enqueueErrorSnackBar } = useSnackBar();
@@ -240,13 +239,9 @@ export const SettingsObjectNewFieldConfigure = () => {
description={t`Customize field settings`}
/>
<SettingsDataModelFieldSettingsFormCard
fieldMetadataItem={{
icon: formConfig.watch('icon'),
label: formConfig.watch('label') || 'New Field',
settings: formConfig.watch('settings') || null,
type: fieldType as FieldMetadataType,
}}
objectMetadataItem={activeObjectMetadataItem}
fieldType={fieldType}
existingFieldMetadataId={''}
objectNameSingular={activeObjectMetadataItem.nameSingular}
/>
</Section>
<Section>
@@ -2,19 +2,25 @@ import { type ReactNode, useEffect, useState } from 'react';
import { useSetRecoilState } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
export const JestObjectMetadataItemSetter = ({
children,
objectMetadataItems,
}: {
children: ReactNode;
objectMetadataItems?: ObjectMetadataItem[];
}) => {
const setObjectMetadataItems = useSetRecoilState(objectMetadataItemsState);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setObjectMetadataItems(generatedMockObjectMetadataItems);
setObjectMetadataItems(
objectMetadataItems ?? generatedMockObjectMetadataItems,
);
setIsLoaded(true);
}, [setObjectMetadataItems]);
}, [objectMetadataItems, setObjectMetadataItems]);
return isLoaded ? <>{children}</> : null;
};
@@ -4,6 +4,7 @@ import { RecoilRoot, type MutableSnapshot } from 'recoil';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
@@ -15,12 +16,14 @@ export const getJestMetadataAndApolloMocksWrapper = ({
apolloMocks,
cache,
onInitializeRecoilSnapshot,
objectMetadataItems,
}: {
cache?: InMemoryCache;
apolloMocks?:
| readonly MockedResponse<Record<string, any>, Record<string, any>>[]
| undefined;
onInitializeRecoilSnapshot?: (snapshot: MutableSnapshot) => void;
objectMetadataItems?: ObjectMetadataItem[];
}) => {
return ({ children }: { children: ReactNode }) => (
<RecoilRoot initializeState={onInitializeRecoilSnapshot}>
@@ -34,7 +37,9 @@ export const getJestMetadataAndApolloMocksWrapper = ({
<ViewComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
>
<JestObjectMetadataItemSetter>
<JestObjectMetadataItemSetter
objectMetadataItems={objectMetadataItems}
>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'instanceId' }}
>
@@ -0,0 +1,13 @@
import { capitalize } from '@/utils/strings';
type ComputeMorphRelationFieldJoinColumnNameArgs = {
name: string;
targetObjectMetadataNameSingular: string;
};
export const computeMorphRelationFieldJoinColumnName = ({
name,
targetObjectMetadataNameSingular,
}: ComputeMorphRelationFieldJoinColumnNameArgs) => {
return `${name}${capitalize(targetObjectMetadataNameSingular)}Id`;
};
@@ -0,0 +1,34 @@
import { capitalize } from '@/utils/strings';
enum RelationType {
MANY_TO_ONE = 'MANY_TO_ONE',
ONE_TO_MANY = 'ONE_TO_MANY'
}
type ComputeMorphRelationFieldNameArgs = {
fieldName: string;
relationDirection: RelationType;
nameSingular: string;
namePlural: string;
};
export const computeMorphRelationFieldName = ({
fieldName,
relationDirection,
nameSingular,
namePlural,
}: ComputeMorphRelationFieldNameArgs): string => {
if (relationDirection === RelationType.MANY_TO_ONE) {
return `${fieldName}${capitalize(nameSingular)}`;
}
if (relationDirection === RelationType.ONE_TO_MANY) {
return `${fieldName}${capitalize(namePlural)}`;
}
throw new Error(
`Invalid relation direction: ${relationDirection} for field ${fieldName}`,
);
};
@@ -12,6 +12,8 @@ export { findByProperty } from './array/findByProperty';
export { assertUnreachable } from './assertUnreachable';
export { deepMerge } from './deepMerge';
export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields';
export { computeMorphRelationFieldJoinColumnName } from './fieldMetadata/compute-morph-relation-field-join-column-name.util';
export { computeMorphRelationFieldName } from './fieldMetadata/compute-morph-relation-field-name.util';
export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';
export { fromArrayToUniqueKeyRecord } from './from-array-to-unique-key-record.util';
export { fromArrayToValuesByKeyRecord } from './fromArrayToValuesByKeyRecord.util';