Morph many to one picker (#14155)

This PR follows the multiSelect PR merged previously. It will enable
morph relation Many to One to be handled from the table, using a
singleSelect picker

Main point : I decided to change the singleSelect API to take an array
of **objectMetadataName** instead of only one to deal with both our
usecases.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim
2025-10-07 17:25:11 +02:00
committed by GitHub
parent 9f9c2d85ca
commit f60817a1e1
138 changed files with 2116 additions and 1763 deletions
@@ -1,187 +0,0 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpdateMultipleRecordsFromManyObjects } from '@/object-record/hooks/useUpdateMultipleRecordsFromManyObjects';
import { getTargetFieldMetadataName } from '@/object-record/multiple-objects/utils/getTargetFieldMetadataName';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useAttachMorphRelatedRecordFromRecord = () => {
const apolloCoreClient = useApolloCoreClient();
const { fieldDefinition } = useContext(FieldContext);
const { objectMetadataItems } = useObjectMetadataItems();
const { updateMultipleRecordsFromManyObjects } =
useUpdateMultipleRecordsFromManyObjects();
if (!isFieldMorphRelation(fieldDefinition)) {
throw new Error('Field is not a morph relation');
}
const objectNameSingular =
fieldDefinition.metadata.morphRelations[0].sourceObjectMetadata
.nameSingular;
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular,
);
if (!isDefined(objectMetadataItem)) {
throw new Error('Could not find object metadata item');
}
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const updateOneRecordAndAttachMorphRelations = useRecoilCallback(
({ set, snapshot }) =>
async ({
recordId,
relatedRecordId,
objectNameSingulars,
}: {
recordId: string;
relatedRecordId: string;
objectNameSingulars: string[];
}) => {
const relatedObjectMetadataItems = objectMetadataItems.filter(
(objectMetadataItem) =>
objectNameSingulars.includes(objectMetadataItem.nameSingular),
);
const parentRecord = snapshot
.getLoadable(recordStoreFamilyState(recordId))
.getValue();
if (isDefined(parentRecord)) {
relatedObjectMetadataItems.forEach((relatedObjectMetadataItem) => {
const currentMorphFieldValue =
parentRecord[fieldDefinition.metadata.fieldName];
const objectRecordFromCache = getRecordFromCache({
objectMetadataItem: relatedObjectMetadataItem,
recordId: relatedRecordId,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
if (!isDefined(objectRecordFromCache)) {
return;
}
set(recordStoreFamilyState(recordId), {
...parentRecord,
[fieldDefinition.metadata.fieldName]: [
...currentMorphFieldValue,
objectRecordFromCache,
],
});
});
}
const relatedObjectMetadataItemsWithCachedRecord =
relatedObjectMetadataItems
.map((objectMetadataItem) => {
const cachedRelatedRecord = getRecordFromCache<ObjectRecord>({
objectMetadataItem,
recordId: relatedRecordId,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
return {
cachedRelatedRecord,
objectMetadataItem,
};
})
.find((item) => isDefined(item.cachedRelatedRecord));
if (!relatedObjectMetadataItemsWithCachedRecord) {
throw new Error('Could not find cached related record');
}
const {
objectMetadataItem: relatedObjectMetadataItem,
cachedRelatedRecord,
} = relatedObjectMetadataItemsWithCachedRecord;
if (
!isDefined(cachedRelatedRecord) ||
!isDefined(cachedRelatedRecord.id)
) {
throw new Error('Could not find cached related record');
}
const fieldOnRelatedObject = getTargetFieldMetadataName({
fieldDefinition,
objectNameSingular: relatedObjectMetadataItem.nameSingular,
});
if (!isDefined(fieldOnRelatedObject)) {
throw new Error('Could not find field on related object');
}
const previousRecordId =
cachedRelatedRecord?.[`${fieldOnRelatedObject}Id`];
if (isDefined(previousRecordId)) {
const previousRecord = getRecordFromCache<ObjectRecord>({
objectMetadataItem,
recordId: previousRecordId,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
const previousRecordWithRelation = {
...cachedRelatedRecord,
[fieldOnRelatedObject]: previousRecord,
};
const gqlFields = computeDepthOneRecordGqlFieldsFromRecord({
objectMetadataItem: relatedObjectMetadataItem,
record: previousRecordWithRelation,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem: relatedObjectMetadataItem,
cache: apolloCoreClient.cache,
record: {
...cachedRelatedRecord,
[fieldOnRelatedObject]: previousRecord,
},
recordGqlFields: gqlFields,
objectPermissionsByObjectMetadataId,
});
}
const updatedManyRecordsArgs = [
{
idToUpdate: relatedRecordId,
objectNameSingulars,
relatedRecordId: recordId,
// recordGqlFields: gqlFields,
},
];
await updateMultipleRecordsFromManyObjects(updatedManyRecordsArgs);
},
[
fieldDefinition,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
updateMultipleRecordsFromManyObjects,
apolloCoreClient.cache,
objectMetadataItem,
],
);
return { updateOneRecordAndAttachMorphRelations };
};
@@ -1,118 +0,0 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { CustomError, isDefined } from 'twenty-shared/utils';
type useAttachRelatedRecordFromRecordProps = {
recordObjectNameSingular: string;
fieldNameOnRecordObject: string;
};
export const useAttachRelatedRecordFromRecord = ({
recordObjectNameSingular,
fieldNameOnRecordObject,
}: useAttachRelatedRecordFromRecordProps) => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: recordObjectNameSingular,
});
const fieldOnObject = objectMetadataItem.readableFields.find((field) => {
return field.name === fieldNameOnRecordObject;
});
const relatedRecordObjectNameSingular =
fieldOnObject?.relation?.targetObjectMetadata.nameSingular;
if (!relatedRecordObjectNameSingular) {
throw new CustomError(
`Could not find record related to ${recordObjectNameSingular}`,
'RELATED_RECORD_NOT_FOUND',
);
}
const { objectMetadataItem: relatedObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: relatedRecordObjectNameSingular,
});
const fieldOnRelatedObject =
fieldOnObject?.relation?.targetFieldMetadata.name;
if (!fieldOnRelatedObject) {
throw new CustomError(
`Missing target field for ${fieldNameOnRecordObject}`,
'MISSING_TARGET_FIELD',
);
}
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: relatedRecordObjectNameSingular,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: recordObjectNameSingular,
});
const getRelatedRecordFromCache = useGetRecordFromCache({
objectNameSingular: relatedRecordObjectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const updateOneRecordAndAttachRelations = async ({
recordId,
relatedRecordId,
}: {
recordId: string;
relatedRecordId: string;
}) => {
const cachedRelatedRecord =
getRelatedRecordFromCache<ObjectRecord>(relatedRecordId);
if (!cachedRelatedRecord) {
throw new Error('Could not find cached related record');
}
const previousRecordId = cachedRelatedRecord?.[`${fieldOnRelatedObject}Id`];
if (isDefined(previousRecordId)) {
const previousRecord = getRecordFromCache<ObjectRecord>(previousRecordId);
const previousRecordWithRelation = {
...cachedRelatedRecord,
[fieldOnRelatedObject]: previousRecord,
};
const gqlFields = computeDepthOneRecordGqlFieldsFromRecord({
objectMetadataItem: relatedObjectMetadataItem,
record: previousRecordWithRelation,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem: relatedObjectMetadataItem,
cache: apolloCoreClient.cache,
record: {
...cachedRelatedRecord,
[fieldOnRelatedObject]: previousRecord,
},
recordGqlFields: gqlFields,
objectPermissionsByObjectMetadataId,
});
}
await updateOneRecord({
idToUpdate: relatedRecordId,
updateOneRecordInput: {
[`${fieldOnRelatedObject}Id`]: recordId,
},
});
};
return { updateOneRecordAndAttachRelations };
};
@@ -1,170 +0,0 @@
import { type Reference } from '@apollo/client';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { getRefName } from '@/object-record/cache/utils/getRefName';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpdateMultipleRecordsFromManyObjects } from '@/object-record/hooks/useUpdateMultipleRecordsFromManyObjects';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useDetachMorphRelatedRecordFromRecord = () => {
const apolloCoreClient = useApolloCoreClient();
const { fieldDefinition } = useContext(FieldContext);
const { objectMetadataItems } = useObjectMetadataItems();
const { updateMultipleRecordsFromManyObjects } =
useUpdateMultipleRecordsFromManyObjects();
if (!isFieldMorphRelation(fieldDefinition)) {
throw new Error('Field is not a morph relation');
}
const objectNameSingular =
fieldDefinition.metadata.morphRelations[0].sourceObjectMetadata
.nameSingular;
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular,
);
if (!isDefined(objectMetadataItem)) {
throw new Error('Could not find object metadata item');
}
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const updateOneRecordAndDetachMorphRelations = useRecoilCallback(
({ set, snapshot }) =>
async ({
recordId,
relatedRecordId,
objectNameSingulars,
}: {
recordId: string;
relatedRecordId: string;
objectNameSingulars: string[];
}) => {
const relatedObjectMetadataItems = objectMetadataItems.filter(
(objectMetadataItem) =>
objectNameSingulars.includes(objectMetadataItem.nameSingular),
);
const parentRecord = snapshot
.getLoadable(recordStoreFamilyState(recordId))
.getValue();
if (isDefined(parentRecord)) {
relatedObjectMetadataItems.forEach((relatedObjectMetadataItem) => {
const currentMorphFieldValue = parentRecord[
fieldDefinition.metadata.fieldName
] as ObjectRecord[];
const objectRecordFromCache = getRecordFromCache({
objectMetadataItem: relatedObjectMetadataItem,
recordId: relatedRecordId,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
if (!isDefined(objectRecordFromCache)) {
return;
}
if (Array.isArray(currentMorphFieldValue)) {
set(recordStoreFamilyState(recordId), {
...parentRecord,
[fieldDefinition.metadata.fieldName]:
currentMorphFieldValue.filter(
(record) => record.id !== relatedRecordId,
),
});
}
});
}
const relatedObjectMetadataItemsWithCachedRecord =
relatedObjectMetadataItems
.map((objectMetadataItem) => {
const cachedRelatedRecord = getRecordFromCache<ObjectRecord>({
objectMetadataItem,
recordId: relatedRecordId,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
return {
cachedRelatedRecord,
objectMetadataItem,
};
})
.find((item) => isDefined(item.cachedRelatedRecord));
if (!relatedObjectMetadataItemsWithCachedRecord) {
throw new Error('Could not find cached related record');
}
const { objectMetadataItem: relatedObjectMetadataItem } =
relatedObjectMetadataItemsWithCachedRecord;
modifyRecordFromCache({
objectMetadataItem,
cache: apolloCoreClient.cache,
fieldModifiers: {
[fieldDefinition.metadata.fieldName]: (
fieldNameOnRecordObjectConnection,
{ readField },
) => {
const edges = readField<{ node: Reference }[]>(
'edges',
fieldNameOnRecordObjectConnection,
);
if (!edges) return fieldNameOnRecordObjectConnection;
return {
...fieldNameOnRecordObjectConnection,
edges: edges.filter(
(edge) =>
!(
edge.node.__ref ===
getRefName(
relatedObjectMetadataItem.nameSingular,
relatedRecordId,
)
),
),
};
},
},
recordId,
});
const updatedManyRecordsArgs = [
{
idToUpdate: relatedRecordId,
objectNameSingulars,
relatedRecordId: null,
},
];
await updateMultipleRecordsFromManyObjects(updatedManyRecordsArgs);
},
[
fieldDefinition,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
updateMultipleRecordsFromManyObjects,
apolloCoreClient.cache,
objectMetadataItem,
],
);
return { updateOneRecordAndDetachMorphRelations };
};
@@ -1,89 +0,0 @@
import { type Reference } from '@apollo/client';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getRefName } from '@/object-record/cache/utils/getRefName';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
type useDetachRelatedRecordFromRecordProps = {
recordObjectNameSingular: string;
fieldNameOnRecordObject: string;
};
export const useDetachRelatedRecordFromRecord = ({
recordObjectNameSingular,
fieldNameOnRecordObject,
}: useDetachRelatedRecordFromRecordProps) => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: recordObjectNameSingular,
});
const fieldOnObject = objectMetadataItem.readableFields.find((field) => {
return field.name === fieldNameOnRecordObject;
});
const relatedRecordObjectNameSingular =
fieldOnObject?.relation?.targetObjectMetadata.nameSingular;
const fieldOnRelatedObject =
fieldOnObject?.relation?.targetFieldMetadata.name;
if (!relatedRecordObjectNameSingular) {
throw new Error(
`Could not find record related to ${recordObjectNameSingular}`,
);
}
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: relatedRecordObjectNameSingular,
});
const updateOneRecordAndDetachRelations = async ({
recordId,
relatedRecordId,
}: {
recordId: string;
relatedRecordId: string;
}) => {
modifyRecordFromCache({
objectMetadataItem,
cache: apolloCoreClient.cache,
fieldModifiers: {
[fieldNameOnRecordObject]: (
fieldNameOnRecordObjectConnection,
{ readField },
) => {
const edges = readField<{ node: Reference }[]>(
'edges',
fieldNameOnRecordObjectConnection,
);
if (!edges) return fieldNameOnRecordObjectConnection;
return {
...fieldNameOnRecordObjectConnection,
edges: edges.filter(
(edge) =>
!(
edge.node.__ref ===
getRefName(relatedRecordObjectNameSingular, relatedRecordId)
),
),
};
},
},
recordId,
});
await updateOneRecord({
idToUpdate: relatedRecordId,
updateOneRecordInput: {
[`${fieldOnRelatedObject}Id`]: null,
},
});
};
return { updateOneRecordAndDetachRelations };
};
@@ -0,0 +1,111 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpdateOneRecordV2 } from '@/object-record/hooks/useUpdateOneRecordV2';
import { CustomError, isDefined } from 'twenty-shared/utils';
export const useRecordOneToManyFieldAttachTargetRecord = () => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { updateOneRecord } = useUpdateOneRecordV2();
const recordOneToManyFieldAttachTargetRecord = async ({
sourceObjectNameSingular,
targetObjectNameSingular,
targetGQLFieldName,
sourceRecordId,
targetRecordId,
}: {
sourceObjectNameSingular: string;
targetObjectNameSingular: string;
targetGQLFieldName: string;
sourceRecordId: string;
targetRecordId: string;
}) => {
const targetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === targetObjectNameSingular,
);
const sourceObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === sourceObjectNameSingular,
);
if (!targetObjectMetadataItem) {
throw new CustomError(
`Could not find related object metadata for ${targetObjectNameSingular}`,
'RELATED_OBJECT_METADATA_NOT_FOUND',
);
}
if (!sourceObjectMetadataItem) {
throw new CustomError(
`Could not find source object metadata for ${sourceObjectNameSingular}`,
'SOURCE_OBJECT_METADATA_NOT_FOUND',
);
}
const cachedTargetRecord = getRecordFromCache({
objectMetadataItem: targetObjectMetadataItem,
recordId: targetRecordId,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
if (!cachedTargetRecord) {
throw new Error('Could not find cached related record');
}
const previousRecordId = cachedTargetRecord?.[`${targetGQLFieldName}Id`];
if (isDefined(previousRecordId)) {
const previousRecord = getRecordFromCache({
objectMetadataItem: sourceObjectMetadataItem,
recordId: previousRecordId,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
const previousRecordWithRelation = {
...cachedTargetRecord,
[targetGQLFieldName]: previousRecord,
};
const gqlFields = computeDepthOneRecordGqlFieldsFromRecord({
objectMetadataItem: targetObjectMetadataItem,
record: previousRecordWithRelation,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem: targetObjectMetadataItem,
cache: apolloCoreClient.cache,
record: {
...cachedTargetRecord,
[targetGQLFieldName]: previousRecord,
},
recordGqlFields: gqlFields,
objectPermissionsByObjectMetadataId,
});
}
await updateOneRecord({
objectNameSingular: targetObjectNameSingular,
idToUpdate: targetRecordId,
updateOneRecordInput: {
[`${targetGQLFieldName}Id`]: sourceRecordId,
},
});
};
return { recordOneToManyFieldAttachTargetRecord };
};
@@ -0,0 +1,92 @@
import { type Reference } from '@apollo/client';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getRefName } from '@/object-record/cache/utils/getRefName';
import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache';
import { useUpdateOneRecordV2 } from '@/object-record/hooks/useUpdateOneRecordV2';
import { CustomError } from 'twenty-shared/utils';
export const useRecordOneToManyFieldDetachTargetRecord = () => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { updateOneRecord } = useUpdateOneRecordV2();
const recordOneToManyFieldDetachTargetRecord = async ({
sourceObjectNameSingular,
targetObjectNameSingular,
targetGQLFieldName,
sourceRecordId,
targetRecordId,
}: {
sourceObjectNameSingular: string;
targetObjectNameSingular: string;
targetGQLFieldName: string;
sourceRecordId: string;
targetRecordId: string;
}) => {
const targetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === targetObjectNameSingular,
);
const sourceObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === sourceObjectNameSingular,
);
if (!targetObjectMetadataItem) {
throw new CustomError(
`Could not find related object metadata for ${targetObjectNameSingular}`,
'RELATED_OBJECT_METADATA_NOT_FOUND',
);
}
if (!sourceObjectMetadataItem) {
throw new CustomError(
`Could not find source object metadata for ${sourceObjectNameSingular}`,
'SOURCE_OBJECT_METADATA_NOT_FOUND',
);
}
modifyRecordFromCache({
objectMetadataItem: sourceObjectMetadataItem,
cache: apolloCoreClient.cache,
fieldModifiers: {
[targetGQLFieldName]: (
fieldNameOnRecordObjectConnection,
{ readField },
) => {
const edges = readField<{ node: Reference }[]>(
'edges',
fieldNameOnRecordObjectConnection,
);
if (!edges) return fieldNameOnRecordObjectConnection;
return {
...fieldNameOnRecordObjectConnection,
edges: edges.filter(
(edge) =>
!(
edge.node.__ref ===
getRefName(targetObjectNameSingular, targetRecordId)
),
),
};
},
},
recordId: sourceRecordId,
});
await updateOneRecord({
objectNameSingular: targetObjectNameSingular,
idToUpdate: targetRecordId,
updateOneRecordInput: {
[`${targetGQLFieldName}Id`]: null,
},
});
};
return { recordOneToManyFieldDetachTargetRecord };
};
@@ -1,284 +0,0 @@
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { generateUpdateOneRecordMutation } from '@/object-record/multiple-objects/utils/generateUpdateOneRecordMutation';
import { getTargetFieldMetadataName } from '@/object-record/multiple-objects/utils/getTargetFieldMetadataName';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getAggregateQueryName } from '@/object-record/utils/getAggregateQueryName';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isNull } from '@sniptt/guards';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue';
type UpdateManyRecordArgs = {
idToUpdate: string;
relatedRecordId: string | null;
objectNameSingulars: string[];
recordGqlFields?: Record<string, any>;
};
export const useUpdateMultipleRecordsFromManyObjects = () => {
const { fieldDefinition } = useContext(FieldContext);
const apolloCoreClient = useApolloCoreClient();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const updateMultipleRecordsFromManyObjects = async (
updatedManyRecordsArgs: UpdateManyRecordArgs[],
) => {
for (const {
idToUpdate,
objectNameSingulars,
relatedRecordId,
recordGqlFields,
} of updatedManyRecordsArgs) {
const objectMetadataItemArray = objectMetadataItems.filter(
(objectMetadataItem) =>
objectNameSingulars.includes(objectMetadataItem.nameSingular),
);
if (objectMetadataItemArray.length === 0) {
throw new CustomError(
`Object metadata item not found ${objectNameSingulars.join(', ')}`,
'OBJECT_METADATA_ITEM_NOT_FOUND',
);
}
const objectMetadataItemWithCachedRecord = objectMetadataItemArray
.map((objectMetadataItem) => {
const cachedRecord = getRecordFromCache({
objectMetadataItem,
recordId: idToUpdate,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
return {
cachedRecord,
objectMetadataItem,
};
})
.find((item) => isDefined(item.cachedRecord));
if (!isDefined(objectMetadataItemWithCachedRecord)) {
throw new CustomError(
`Record not found ${idToUpdate}`,
'RECORD_NOT_FOUND_IN_OBJECT_METADATA_ITEM',
);
}
const { objectMetadataItem, cachedRecord } =
objectMetadataItemWithCachedRecord;
if (
!isFieldRelation(fieldDefinition) &&
!isFieldMorphRelation(fieldDefinition)
) {
throw new CustomError(
`Should never happen`,
'TARGET_FIELD_NAME_NOT_FOUND',
);
}
const targetFieldName = getTargetFieldMetadataName({
fieldDefinition,
objectNameSingular: objectMetadataItem.nameSingular,
});
if (!isDefined(targetFieldName)) {
throw new CustomError(
`Cannot find Target field name for the (morph) relation field ${fieldDefinition.metadata.fieldName} on ${objectMetadataItem.nameSingular}`,
'TARGET_FIELD_NAME_NOT_FOUND',
);
}
const updateOneRecordInput = {
[`${targetFieldName}Id`]: relatedRecordId,
};
const optimisticRecordInput = computeOptimisticRecordFromInput({
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember,
recordInput: updateOneRecordInput,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
const computedRecordGqlFields =
recordGqlFields ??
generateDepthOneRecordGqlFields({ objectMetadataItem });
const cachedRecordWithConnection = getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
const computedOptimisticRecord = {
...cachedRecord,
...optimisticRecordInput,
id: idToUpdate,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
};
const optimisticRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: computedOptimisticRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
const shouldHandleOptimisticCache =
!isNull(cachedRecord) &&
isDefined(optimisticRecordWithConnection) &&
isDefined(cachedRecordWithConnection);
if (shouldHandleOptimisticCache) {
const recordGqlFields = computeDepthOneRecordGqlFieldsFromRecord({
objectMetadataItem,
record: optimisticRecordInput,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: computedOptimisticRecord,
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
triggerUpdateRecordOptimisticEffect({
cache: apolloCoreClient.cache,
objectMetadataItem,
currentRecord: cachedRecordWithConnection,
updatedRecord: optimisticRecordWithConnection,
objectMetadataItems,
});
}
const mutationResponseField = getUpdateOneRecordMutationResponseField(
objectMetadataItem.nameSingular,
);
const sanitizedInput = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
}),
};
const updateOneRecordMutation = generateUpdateOneRecordMutation({
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
objectPermissionsByObjectMetadataId,
});
const updatedRecord = await apolloCoreClient
.mutate({
mutation: updateOneRecordMutation,
variables: {
idToUpdate,
input: sanitizedInput,
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!isDefined(record)) return;
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
currentRecord: computedOptimisticRecord,
updatedRecord: record,
objectMetadataItems,
});
},
})
.catch((error: Error) => {
if (!shouldHandleOptimisticCache) {
throw error;
}
const cachedRecordKeys = new Set(Object.keys(cachedRecord));
const recordKeysAddedByOptimisticCache = Object.keys(
optimisticRecordInput,
).filter((diffKey) => !cachedRecordKeys.has(diffKey));
const recordGqlFields = {
...computeDepthOneRecordGqlFieldsFromRecord({
objectMetadataItem,
record: cachedRecord,
}),
...buildRecordFromKeysWithSameValue(
recordKeysAddedByOptimisticCache,
true,
),
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: {
...cachedRecord,
...buildRecordFromKeysWithSameValue(
recordKeysAddedByOptimisticCache,
null,
),
},
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
triggerUpdateRecordOptimisticEffect({
cache: apolloCoreClient.cache,
objectMetadataItem,
currentRecord: optimisticRecordWithConnection,
updatedRecord: cachedRecordWithConnection,
objectMetadataItems,
});
throw error;
});
const refetchAggregateQueries = async () => {
const queryName = getAggregateQueryName(objectMetadataItem.namePlural);
await apolloCoreClient.refetchQueries({
include: [queryName],
});
};
await refetchAggregateQueries();
return updatedRecord?.data?.[mutationResponseField] ?? null;
}
};
return {
updateMultipleRecordsFromManyObjects,
};
};
@@ -2,10 +2,10 @@ import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { generateUpdateOneRecordMutation } from '@/object-metadata/utils/generateUpdateOneRecordMutation';
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { generateUpdateOneRecordMutation } from '@/object-record/multiple-objects/utils/generateUpdateOneRecordMutation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useUpdateOneRecordMutation = ({
@@ -0,0 +1,222 @@
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { generateUpdateOneRecordMutation } from '@/object-metadata/utils/generateUpdateOneRecordMutation';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { computeDepthOneRecordGqlFieldsFromRecord } from '@/object-record/graphql/utils/computeDepthOneRecordGqlFieldsFromRecord';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isNull } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue';
type UpdateOneRecordArgs<UpdatedObjectRecord> = {
idToUpdate: string;
updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
optimisticRecord?: Partial<ObjectRecord>;
objectNameSingular: string;
recordGqlFields?: Record<string, any>;
};
export const useUpdateOneRecordV2 = () => {
const apolloCoreClient = useApolloCoreClient();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const updateOneRecord = async <
UpdatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
recordGqlFields,
idToUpdate,
updateOneRecordInput,
optimisticRecord,
}: UpdateOneRecordArgs<UpdatedObjectRecord>) => {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular,
);
if (!objectMetadataItem) {
throw new Error(
`Object metadata item not found for ${objectNameSingular}`,
);
}
const computedRecordGqlFields =
recordGqlFields ??
generateDepthOneRecordGqlFields({ objectMetadataItem });
const optimisticRecordInput =
optimisticRecord ??
computeOptimisticRecordFromInput({
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember,
recordInput: updateOneRecordInput,
cache: apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
});
const cachedRecord = getRecordFromCache({
cache: apolloCoreClient.cache,
objectMetadataItem,
objectMetadataItems,
recordId: idToUpdate,
recordGqlFields: computedRecordGqlFields,
objectPermissionsByObjectMetadataId,
});
const cachedRecordWithConnection = getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
const computedOptimisticRecord = {
...cachedRecord,
...optimisticRecordInput,
id: idToUpdate,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
};
const optimisticRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: computedOptimisticRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: computedRecordGqlFields,
computeReferences: false,
});
const shouldHandleOptimisticCache =
!isNull(cachedRecord) &&
isDefined(optimisticRecordWithConnection) &&
isDefined(cachedRecordWithConnection);
if (shouldHandleOptimisticCache) {
const recordGqlFields = computeDepthOneRecordGqlFieldsFromRecord({
objectMetadataItem,
record: optimisticRecordInput,
});
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: computedOptimisticRecord,
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
triggerUpdateRecordOptimisticEffect({
cache: apolloCoreClient.cache,
objectMetadataItem,
currentRecord: cachedRecordWithConnection,
updatedRecord: optimisticRecordWithConnection,
objectMetadataItems,
});
}
const mutationResponseField =
getUpdateOneRecordMutationResponseField(objectNameSingular);
const sanitizedInput = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
}),
};
const updateOneRecordMutation = generateUpdateOneRecordMutation({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences: false,
objectPermissionsByObjectMetadataId,
});
const updatedRecord = await apolloCoreClient
.mutate({
mutation: updateOneRecordMutation,
variables: {
idToUpdate,
input: sanitizedInput,
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!isDefined(record)) return;
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
currentRecord: computedOptimisticRecord,
updatedRecord: record,
objectMetadataItems,
});
},
})
.catch((error: Error) => {
if (!shouldHandleOptimisticCache) {
throw error;
}
const cachedRecordKeys = new Set(Object.keys(cachedRecord));
const recordKeysAddedByOptimisticCache = Object.keys(
optimisticRecordInput,
).filter((diffKey) => !cachedRecordKeys.has(diffKey));
const recordGqlFields = {
...computeDepthOneRecordGqlFieldsFromRecord({
objectMetadataItem,
record: cachedRecord,
}),
...buildRecordFromKeysWithSameValue(
recordKeysAddedByOptimisticCache,
true,
),
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloCoreClient.cache,
record: {
...cachedRecord,
...buildRecordFromKeysWithSameValue(
recordKeysAddedByOptimisticCache,
null,
),
},
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
triggerUpdateRecordOptimisticEffect({
cache: apolloCoreClient.cache,
objectMetadataItem,
currentRecord: optimisticRecordWithConnection,
updatedRecord: cachedRecordWithConnection,
objectMetadataItems,
});
throw error;
});
return updatedRecord?.data?.[mutationResponseField] ?? null;
};
return {
updateOneRecord,
};
};
@@ -1,53 +0,0 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { gql } from '@apollo/client';
import { type ObjectPermissions } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
export const generateUpdateOneRecordMutation = ({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
objectPermissionsByObjectMetadataId,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
recordGqlFields?: RecordGqlOperationGqlRecordFields;
computeReferences: boolean;
objectPermissionsByObjectMetadataId: Record<
string,
ObjectPermissions & { objectMetadataId: string }
>;
}) => {
const appliedRecordGqlFields =
recordGqlFields ??
generateDepthOneRecordGqlFields({
objectMetadataItem,
});
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const mutationResponseField = getUpdateOneRecordMutationResponseField(
objectMetadataItem.nameSingular,
);
const updateOneRecordMutation = gql`
mutation UpdateOne${capitalizedObjectName}($idToUpdate: UUID!, $input: ${capitalizedObjectName}UpdateInput!){
${mutationResponseField}(id: $idToUpdate, data: $input) ${mapObjectMetadataToGraphQLQuery(
{
objectMetadataItems,
objectMetadataItem,
computeReferences,
recordGqlFields: appliedRecordGqlFields,
objectPermissionsByObjectMetadataId,
},
)}
}
`;
return updateOneRecordMutation;
};
@@ -1,25 +0,0 @@
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import {
type FieldMorphRelationMetadata,
type FieldRelationMetadata,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
export const getTargetFieldMetadataName = ({
fieldDefinition,
objectNameSingular,
}: {
fieldDefinition:
| FieldDefinition<FieldMorphRelationMetadata>
| FieldDefinition<FieldRelationMetadata>;
objectNameSingular: string;
}) => {
if (isFieldRelation(fieldDefinition)) {
return fieldDefinition.metadata.targetFieldMetadataName;
} else {
return fieldDefinition.metadata.morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.nameSingular === objectNameSingular,
)?.targetFieldMetadata.name;
}
};
@@ -4,7 +4,6 @@ import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationFromManyFieldInput';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
@@ -22,6 +21,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useUpdateRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationOneToManyFieldInput';
export const RecordDetailRelationSectionDropdownToMany = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
@@ -83,7 +83,7 @@ export const RecordDetailRelationSectionDropdownToMany = () => {
setMultipleRecordPickerSearchFilter('');
}, [setMultipleRecordPickerSearchFilter]);
const { updateRelation } = useUpdateRelationFromManyFieldInput();
const { updateRelation } = useUpdateRelationOneToManyFieldInput();
const { createNewRecordAndOpenRightDrawer } =
useAddNewRecordAndOpenRightDrawer({
@@ -134,7 +134,7 @@ export const RecordDetailRelationSectionDropdownToOne = () => {
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
onRecordSelected={handleRelationPickerEntitySelected}
objectNameSingular={relationObjectMetadataNameSingular}
objectNameSingulars={[relationObjectMetadataNameSingular]}
recordPickerInstanceId={dropdownId}
onCancel={() => closeDropdown(dropdownId)}
onCreate={shouldAllowCreateNew ? handleCreateNew : undefined}
@@ -9,7 +9,7 @@ import { visibleRecordFieldsComponentSelector } from '@/object-record/record-fie
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export const useRecordFieldGqlFields = ({
objectMetadataItem,
@@ -73,10 +73,18 @@ export const useRecordFieldGqlFields = ({
);
}
return fieldMetadataItem.morphRelations.map((morphRelation) => [
morphRelation.sourceFieldMetadata.name,
true,
]);
return fieldMetadataItem.morphRelations.map((morphRelation) => {
const computedFieldName = computeMorphRelationFieldName({
fieldName: fieldMetadataItem.name,
relationType: fieldMetadataItem.settings?.relationType,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
});
return [computedFieldName, true];
});
},
),
);
@@ -19,8 +19,6 @@ import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFi
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones';
import { isFieldRating } from '@/object-record/record-field/ui/types/guards/isFieldRating';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/ui/types/guards/isFieldRelationFromManyObjects';
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';
@@ -28,6 +26,8 @@ import { MorphRelationManyToOneFieldDisplay } from '@/object-record/record-field
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 { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany';
import { isDefined } from 'twenty-shared/utils';
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
@@ -74,9 +74,9 @@ export const FieldDisplay = () => {
return isChipDisplay ? (
<ChipFieldDisplay />
) : isFieldRelationToOneObject(fieldDefinition) ? (
) : isFieldRelationManyToOne(fieldDefinition) ? (
<RelationToOneFieldDisplay />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
) : isFieldRelationOneToMany(fieldDefinition) ? (
<RelationFromManyFieldDisplay />
) : isFieldMorphRelationManyToOne(fieldDefinition) ? (
<MorphRelationManyToOneFieldDisplay />
@@ -8,13 +8,14 @@ import { LinksFieldInput } from '@/object-record/record-field/ui/meta-types/inpu
import { MultiSelectFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiSelectFieldInput';
import { PhonesFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput';
import { RawJsonFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RawJsonFieldInput';
import { RelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RelationFromManyFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/SelectFieldInput';
import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/ui/types/guards/isFieldRelationFromManyObjects';
import { ArrayFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput';
import { MorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MorphRelationManyToOneFieldInput';
import { MorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MorphRelationOneToManyFieldInput';
import { RelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RelationManyToOneFieldInput';
import { RelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RelationOneToManyFieldInput';
import { RichTextFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RichTextFieldInput';
import { isFieldAddress } from '@/object-record/record-field/ui/types/guards/isFieldAddress';
import { isFieldArray } from '@/object-record/record-field/ui/types/guards/isFieldArray';
@@ -25,12 +26,14 @@ import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/is
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
import { isFieldMultiSelect } from '@/object-record/record-field/ui/types/guards/isFieldMultiSelect';
import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber';
import { isFieldRating } from '@/object-record/record-field/ui/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/ui/types/guards/isFieldRawJson';
import { isFieldRelationToOneObject } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneObject';
import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany';
import { isFieldRichTextV2 } from '@/object-record/record-field/ui/types/guards/isFieldRichTextV2';
import { isFieldSelect } from '@/object-record/record-field/ui/types/guards/isFieldSelect';
import { FieldContext } from '../contexts/FieldContext';
@@ -39,7 +42,6 @@ import { CurrencyFieldInput } from '../meta-types/input/components/CurrencyField
import { DateTimeFieldInput } from '../meta-types/input/components/DateTimeFieldInput';
import { NumberFieldInput } from '../meta-types/input/components/NumberFieldInput';
import { RatingFieldInput } from '../meta-types/input/components/RatingFieldInput';
import { RelationToOneFieldInput } from '../meta-types/input/components/RelationToOneFieldInput';
import { TextFieldInput } from '../meta-types/input/components/TextFieldInput';
import { isFieldText } from '../types/guards/isFieldText';
@@ -48,10 +50,12 @@ export const FieldInput = () => {
return (
<>
{isFieldRelationToOneObject(fieldDefinition) ? (
<RelationToOneFieldInput />
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
<RelationFromManyFieldInput />
{isFieldRelationManyToOne(fieldDefinition) ? (
<RelationManyToOneFieldInput />
) : isFieldRelationOneToMany(fieldDefinition) ? (
<RelationOneToManyFieldInput />
) : isFieldMorphRelationManyToOne(fieldDefinition) ? (
<MorphRelationManyToOneFieldInput />
) : isFieldMorphRelationOneToMany(fieldDefinition) ? (
<MorphRelationOneToManyFieldInput />
) : isFieldPhones(fieldDefinition) ? (
@@ -42,7 +42,7 @@ import { isFieldMultiSelect } from '@/object-record/record-field/ui/types/guards
import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber';
import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones';
import { isFieldRawJson } from '@/object-record/record-field/ui/types/guards/isFieldRawJson';
import { isFieldRelationToOneObject } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneObject';
import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { isFieldRichTextV2 } from '@/object-record/record-field/ui/types/guards/isFieldRichTextV2';
import { isFieldSelect } from '@/object-record/record-field/ui/types/guards/isFieldSelect';
import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText';
@@ -210,7 +210,7 @@ export const FormFieldInput = ({
readonly={readonly}
placeholder={placeholder}
/>
) : isFieldRelationToOneObject(field) ? (
) : isFieldRelationManyToOne(field) ? (
<FormRelationToOneFieldInput
label={field.label}
objectNameSingular={field.metadata.relationObjectMetadataNameSingular}
@@ -41,7 +41,7 @@ export const FormRelationToOneFieldInput = ({
: defaultValue
}
onChange={onChange}
objectNameSingular={objectNameSingular}
objectNameSingulars={[objectNameSingular]}
disabled={readonly}
VariablePicker={VariablePicker}
/>
@@ -16,10 +16,9 @@ import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback, useId } from 'react';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { CustomError, isDefined, isValidUuid } from 'twenty-shared/utils';
import { IconChevronDown, IconForbid } from 'twenty-ui/display';
const StyledFormSelectContainer = styled(FormFieldInputInnerContainer)<{
@@ -63,7 +62,7 @@ export type FormSingleRecordPickerProps = {
label?: string;
defaultValue?: RecordId | Variable;
onChange: (value: RecordId | Variable | null) => void;
objectNameSingular: string;
objectNameSingulars: string[];
disabled?: boolean;
testId?: string;
VariablePicker?: VariablePickerComponent;
@@ -72,14 +71,12 @@ export type FormSingleRecordPickerProps = {
export const FormSingleRecordPicker = ({
label,
defaultValue,
objectNameSingular,
objectNameSingulars,
onChange,
disabled,
testId,
VariablePicker,
}: FormSingleRecordPickerProps) => {
const { t } = useLingui();
const theme = useTheme();
const draftValue: FormSingleRecordPickerValue = isStandaloneVariableString(
defaultValue,
@@ -93,12 +90,19 @@ export const FormSingleRecordPicker = ({
value: defaultValue || '',
};
if (objectNameSingulars.length === 0) {
throw new CustomError(
'Object is missing while building form single record picker',
'FORM_SINGLE_RECORD_PICKER_OBJECT_NAME_SINGULAR_IS_MISSING',
);
}
const { record: selectedRecord } = useFindOneRecord({
objectRecordId:
isDefined(defaultValue) && !isStandaloneVariableString(defaultValue)
? defaultValue
: '',
objectNameSingular,
objectNameSingular: objectNameSingulars[0],
withSoftDeleted: true,
skip: !isDefined(defaultValue) || !isValidUuid(defaultValue),
});
@@ -171,7 +175,7 @@ export const FormSingleRecordPicker = ({
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
objectNameSingular={objectNameSingulars[0]}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
@@ -193,7 +197,7 @@ export const FormSingleRecordPicker = ({
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
objectNameSingular={objectNameSingulars[0]}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
@@ -210,10 +214,10 @@ export const FormSingleRecordPicker = ({
focusId={dropdownId}
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
emptyLabel={t`No ${objectNameSingular}`}
emptyLabel={'No ' + objectNameSingulars.join(' or ')}
onCancel={() => closeDropdown(dropdownId)}
onRecordSelected={handleRecordSelected}
objectNameSingular={objectNameSingular}
objectNameSingulars={objectNameSingulars}
recordPickerInstanceId={dropdownId}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
@@ -36,7 +36,7 @@ export const Default: Story = {
args: {
label: 'Company',
defaultValue: '123e4567-e89b-12d3-a456-426614174000',
objectNameSingular: 'company',
objectNameSingulars: ['company'],
onChange: fn(),
},
play: async ({ canvasElement }) => {
@@ -56,7 +56,7 @@ export const WithVariables: Story = {
args: {
label: 'Company',
defaultValue: `{{${MOCKED_STEP_ID}.company.id}}`,
objectNameSingular: 'company',
objectNameSingulars: ['company'],
onChange: fn(),
VariablePicker: () => <div>VariablePicker</div>,
},
@@ -80,7 +80,7 @@ export const Disabled: Story = {
args: {
label: 'Company',
defaultValue: '123e4567-e89b-12d3-a456-426614174000',
objectNameSingular: 'company',
objectNameSingulars: ['company'],
onChange: fn(),
disabled: true,
VariablePicker: () => <div>VariablePicker</div>,
@@ -5,7 +5,8 @@ import { type Task } from '@/activities/types/Task';
import { type TaskTarget } from '@/activities/types/TaskTarget';
import { getActivityTargetObjectRecords } from '@/activities/utils/getActivityTargetObjectRecords';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useOpenMorphRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationFromManyFieldInput';
import { useOpenMorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationManyToOneFieldInput';
import { useOpenMorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationOneToManyFieldInput';
import { useOpenRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationFromManyFieldInput';
import { useOpenRelationToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationToOneFieldInput';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
@@ -15,9 +16,10 @@ import {
type FieldRelationValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/ui/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneObject';
import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
@@ -32,12 +34,15 @@ export const useOpenFieldInputEditMode = () => {
const { openRelationFromManyFieldInput } =
useOpenRelationFromManyFieldInput();
const { openMorphRelationFromManyFieldInput } =
useOpenMorphRelationFromManyFieldInput();
const { openMorphRelationOneToManyFieldInput } =
useOpenMorphRelationOneToManyFieldInput();
const { openActivityTargetCellEditMode } =
useOpenActivityTargetCellEditMode();
const { openMorphRelationManyToOneFieldInput } =
useOpenMorphRelationManyToOneFieldInput();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const openFieldInput = useRecoilCallback(
@@ -52,7 +57,7 @@ export const useOpenFieldInputEditMode = () => {
prefix?: string;
}) => {
if (
isFieldRelationFromManyObjects(fieldDefinition) &&
isFieldRelationOneToMany(fieldDefinition) &&
['taskTarget', 'noteTarget'].includes(
fieldDefinition.metadata.relationObjectMetadataNameSingular,
)
@@ -91,7 +96,7 @@ export const useOpenFieldInputEditMode = () => {
return;
}
if (isFieldRelationToOneObject(fieldDefinition)) {
if (isFieldRelationManyToOne(fieldDefinition)) {
openRelationToOneFieldInput({
fieldName: fieldDefinition.metadata.fieldName,
recordId,
@@ -106,7 +111,7 @@ export const useOpenFieldInputEditMode = () => {
throw new Error('Field is not a morph relation one to many');
}
openMorphRelationFromManyFieldInput({
openMorphRelationOneToManyFieldInput({
recordId,
prefix,
fieldDefinition,
@@ -114,7 +119,7 @@ export const useOpenFieldInputEditMode = () => {
return;
}
if (isFieldRelationFromManyObjects(fieldDefinition)) {
if (isFieldRelationOneToMany(fieldDefinition)) {
if (
isDefined(
fieldDefinition.metadata.relationObjectMetadataNameSingular,
@@ -131,6 +136,15 @@ export const useOpenFieldInputEditMode = () => {
}
}
if (isFieldMorphRelationManyToOne(fieldDefinition)) {
openMorphRelationManyToOneFieldInput({
recordId,
prefix,
fieldDefinition,
});
return;
}
pushFocusItemToFocusStack({
focusId: getRecordFieldInputInstanceId({
recordId,
@@ -152,7 +166,8 @@ export const useOpenFieldInputEditMode = () => {
},
[
openActivityTargetCellEditMode,
openMorphRelationFromManyFieldInput,
openMorphRelationManyToOneFieldInput,
openMorphRelationOneToManyFieldInput,
openRelationFromManyFieldInput,
openRelationToOneFieldInput,
pushFocusItemToFocusStack,
@@ -3,6 +3,7 @@ import { useRecoilCallback } from 'recoil';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import {
type FieldMetadata,
type FieldMorphRelationMetadata,
type FieldRelationMetadata,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldAddress } from '@/object-record/record-field/ui/types/guards/isFieldAddress';
@@ -21,21 +22,28 @@ import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFi
import { isFieldPhonesValue } from '@/object-record/record-field/ui/types/guards/isFieldPhonesValue';
import { isFieldRawJson } from '@/object-record/record-field/ui/types/guards/isFieldRawJson';
import { isFieldRawJsonValue } from '@/object-record/record-field/ui/types/guards/isFieldRawJsonValue';
import { isFieldRelationToOneObject } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneObject';
import { isFieldRelationToOneValue } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneValue';
import { isFieldSelect } from '@/object-record/record-field/ui/types/guards/isFieldSelect';
import { isFieldSelectValue } from '@/object-record/record-field/ui/types/guards/isFieldSelectValue';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { isFieldArray } from '@/object-record/record-field/ui/types/guards/isFieldArray';
import { isFieldArrayValue } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { isFieldRelationManyToOneValue } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOneValue';
import { isFieldRichText } from '@/object-record/record-field/ui/types/guards/isFieldRichText';
import { isFieldRichTextV2 } from '@/object-record/record-field/ui/types/guards/isFieldRichTextV2';
import { isFieldRichTextValue } from '@/object-record/record-field/ui/types/guards/isFieldRichTextValue';
import { isFieldRichTextV2Value } from '@/object-record/record-field/ui/types/guards/isFieldRichTextValueV2';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isFieldBoolean } from '../types/guards/isFieldBoolean';
import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue';
@@ -55,13 +63,16 @@ export const usePersistField = ({
}: {
objectMetadataItemId: string;
}) => {
const apolloCoreClient = useApolloCoreClient();
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular: objectMetadataItem?.nameSingular ?? '',
});
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const persistField = useRecoilCallback(
({ set, snapshot }) =>
@@ -74,10 +85,15 @@ export const usePersistField = ({
fieldDefinition: FieldDefinition<FieldMetadata>;
valueToPersist: unknown;
}) => {
const fieldIsRelationToOneObject =
isFieldRelationToOneObject(
const fieldIsRelationManyToOne =
isFieldRelationManyToOne(
fieldDefinition as FieldDefinition<FieldRelationMetadata>,
) && isFieldRelationToOneValue(valueToPersist);
) && isFieldRelationManyToOneValue(valueToPersist);
const fieldIsMorphRelationManyToOne =
isFieldMorphRelationManyToOne(
fieldDefinition as FieldDefinition<FieldMorphRelationMetadata>,
) && isFieldRelationManyToOneValue(valueToPersist);
const fieldIsText =
isFieldText(fieldDefinition) && isFieldTextValue(valueToPersist);
@@ -150,7 +166,8 @@ export const usePersistField = ({
}
const isValuePersistable =
fieldIsRelationToOneObject ||
fieldIsMorphRelationManyToOne ||
fieldIsRelationManyToOne ||
fieldIsText ||
fieldIsBoolean ||
fieldIsEmails ||
@@ -177,23 +194,14 @@ export const usePersistField = ({
.getLoadable(recordStoreFamilySelector({ recordId, fieldName }))
.getValue();
if (
fieldIsRelationToOneObject &&
valueToPersist?.id === currentValue?.id
) {
return;
}
if (fieldIsRelationManyToOne) {
if (
fieldIsRelationManyToOne &&
valueToPersist?.id === currentValue?.id
) {
return;
}
if (isDeeplyEqual(valueToPersist, currentValue)) {
return;
}
set(
recordStoreFamilySelector({ recordId, fieldName }),
valueToPersist,
);
if (fieldIsRelationToOneObject) {
updateOneRecord?.({
idToUpdate: recordId,
updateOneRecordInput: {
@@ -201,6 +209,79 @@ export const usePersistField = ({
valueToPersist?.id ?? null,
},
});
set(
recordStoreFamilySelector({ recordId, fieldName }),
valueToPersist,
);
return;
}
if (fieldIsMorphRelationManyToOne) {
if (isFieldMorphRelation(fieldDefinition)) {
// TODO: this is hack, we should refactor the SingleRecordPicker to pass the updated object
const objectNameSingulars =
fieldDefinition.metadata.morphRelations.map(
(morphRelation) =>
morphRelation.targetObjectMetadata.nameSingular,
);
if (!isDefined(valueToPersist?.id)) {
// Handle detach
return;
}
for (const objectNameSingular of objectNameSingulars) {
const candidateObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular,
);
if (!isDefined(candidateObjectMetadataItem)) {
throw new Error(
`Object metadata item not found for ${objectNameSingular}`,
);
}
const cachedRecord = getRecordFromCache({
cache: apolloCoreClient.cache,
objectMetadataItem: candidateObjectMetadataItem,
objectMetadataItems,
recordId: valueToPersist.id,
objectPermissionsByObjectMetadataId,
});
if (!isDefined(cachedRecord)) {
continue;
}
const computedFieldName = computeMorphRelationFieldName({
fieldName,
relationType: fieldDefinition.metadata.relationType,
targetObjectMetadataNameSingular: objectNameSingular,
targetObjectMetadataNamePlural:
candidateObjectMetadataItem.namePlural,
});
updateOneRecord?.({
idToUpdate: recordId,
updateOneRecordInput: {
[`${computedFieldName}Id`]: valueToPersist.id,
},
});
set(
recordStoreFamilySelector({
recordId,
fieldName: computedFieldName,
}),
valueToPersist,
);
return;
}
}
}
if (isDeeplyEqual(valueToPersist, currentValue)) {
return;
}
@@ -210,6 +291,10 @@ export const usePersistField = ({
[fieldName]: valueToPersist,
},
});
set(
recordStoreFamilySelector({ recordId, fieldName }),
valueToPersist,
);
} else {
throw new Error(
`Invalid value to persist: ${JSON.stringify(
@@ -220,7 +305,12 @@ export const usePersistField = ({
);
}
},
[updateOneRecord],
[
apolloCoreClient.cache,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
updateOneRecord,
],
);
return persistField;
@@ -36,7 +36,6 @@ export const useMorphRelationFromManyFieldDisplay = () => {
recordStoreMorphOneToManyValueWithObjectNameFamilySelector({
recordId,
morphRelations: fieldDefinition.metadata.morphRelations,
fieldName: fieldDefinition.metadata.fieldName,
}),
);
@@ -17,7 +17,6 @@ import { isDefined } from 'twenty-shared/utils';
export const useMorphRelationToOneFieldDisplay = () => {
const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext);
const { chipGeneratorPerObjectPerField } = useContext(
PreComputedChipGeneratorsContext,
);
@@ -34,13 +33,10 @@ export const useMorphRelationToOneFieldDisplay = () => {
const button = fieldDefinition.editButtonIcon;
const fieldName = fieldDefinition.metadata.fieldName;
const morphFieldValueWithObjectName = useRecoilValue(
recordStoreMorphManyToOneValueWithObjectNameFamilySelector({
recordId,
morphRelations: fieldDefinition.metadata.morphRelations,
fieldName,
}),
);
@@ -0,0 +1,69 @@
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState';
import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionLoadingComponentState';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord';
import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useContext } from 'react';
import { IconForbid } from 'twenty-ui/display';
export const MorphRelationManyToOneFieldInput = () => {
const { fieldDefinition } = useContext(FieldContext);
const { onCancel, onSubmit } = useContext(FieldInputEventContext);
const { onCloseTableCell } = useRecordTableBodyContextOrThrow();
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
);
const handleRecordSelected = (
selectedRecord: SingleRecordPickerRecord | null | undefined,
) => {
onSubmit?.({ newValue: selectedRecord?.record ?? null });
onCloseTableCell();
};
const layoutDirection = useRecoilComponentValue(
recordFieldInputLayoutDirectionComponentState,
);
const isLoading = useRecoilComponentValue(
recordFieldInputLayoutDirectionLoadingComponentState,
);
if (isLoading) {
return <></>;
}
if (!isFieldMorphRelationManyToOne(fieldDefinition)) {
return null;
}
const objectNameSingulars = fieldDefinition.metadata.morphRelations.map(
(morphRelation) => morphRelation.targetObjectMetadata.nameSingular,
);
return (
<SingleRecordPicker
focusId={instanceId}
componentInstanceId={instanceId}
EmptyIcon={IconForbid}
emptyLabel={'No ' + fieldDefinition.label}
onCancel={onCancel}
onRecordSelected={handleRecordSelected}
objectNameSingulars={objectNameSingulars}
recordPickerInstanceId={instanceId}
layoutDirection={
layoutDirection === 'downward'
? 'search-bar-on-top'
: 'search-bar-on-bottom'
}
/>
);
};
@@ -1,7 +1,7 @@
import { useContext } from 'react';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useUpdateMorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateMorphRelationManyToOneFieldInput';
import { useUpdateMorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateMorphRelationOneToManyFieldInput';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
@@ -15,8 +15,8 @@ export const MorphRelationOneToManyFieldInput = () => {
const { onSubmit } = useContext(FieldInputEventContext);
const { updateMorphRelationManyToOne } =
useUpdateMorphRelationManyToOneFieldInput();
const { updateMorphRelationOneToMany } =
useUpdateMorphRelationOneToManyFieldInput();
const handleSubmit = () => {
onSubmit?.({ skipPersist: true });
@@ -32,7 +32,7 @@ export const MorphRelationOneToManyFieldInput = () => {
componentInstanceId={instanceId}
onSubmit={handleSubmit}
onChange={(morphItem) => {
updateMorphRelationManyToOne(morphItem);
updateMorphRelationOneToMany(morphItem);
}}
onClickOutside={handleSubmit}
layoutDirection={
@@ -18,7 +18,7 @@ import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconForbid } from 'twenty-ui/display';
export const RelationToOneFieldInput = () => {
export const RelationManyToOneFieldInput = () => {
const { t } = useLingui();
const { fieldDefinition, recordId } = useRelationField<ObjectRecord>();
@@ -86,9 +86,9 @@ export const RelationToOneFieldInput = () => {
onCancel={onCancel}
onCreate={handleCreateNew}
onRecordSelected={handleRecordSelected}
objectNameSingular={
fieldDefinition.metadata.relationObjectMetadataNameSingular
}
objectNameSingulars={[
fieldDefinition.metadata.relationObjectMetadataNameSingular,
]}
recordPickerInstanceId={instanceId}
layoutDirection={
layoutDirection === 'downward'
@@ -10,7 +10,6 @@ import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldCont
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useRelationField } from '@/object-record/record-field/ui/meta-types/hooks/useRelationField';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationFromManyFieldInput';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
@@ -23,8 +22,9 @@ import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useUpdateRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationOneToManyFieldInput';
export const RelationFromManyFieldInput = () => {
export const RelationOneToManyFieldInput = () => {
const { fieldDefinition, recordId } = useContext(FieldContext);
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
@@ -32,7 +32,7 @@ export const RelationFromManyFieldInput = () => {
const { onSubmit } = useContext(FieldInputEventContext);
const { updateRelation } = useUpdateRelationFromManyFieldInput();
const { updateRelation } = useUpdateRelationOneToManyFieldInput();
const fieldName = fieldDefinition.metadata.fieldName;
const objectMetadataNameSingular =
fieldDefinition.metadata.objectMetadataNameSingular;
@@ -22,8 +22,8 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { FieldMetadataType } from 'twenty-shared/types';
import { getCanvasElementForDropdownTesting } from 'twenty-ui/testing';
import { RelationToOneFieldInput } from '../RelationToOneFieldInput';
import { RelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RelationManyToOneFieldInput';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { getFieldInputEventContextProviderWithJestMocks } from './utils/getFieldInputEventContextProviderWithJestMocks';
@@ -56,14 +56,14 @@ const {
handleCancelMocked,
} = getFieldInputEventContextProviderWithJestMocks();
type RelationToOneFieldInputWithContextProps = {
type RelationManyToOneFieldInputWithContextProps = {
value: number;
recordId: string;
};
const RelationToOneFieldInputWithContext = ({
const RelationManyToOneFieldInputWithContext = ({
recordId,
}: RelationToOneFieldInputWithContextProps) => {
}: RelationManyToOneFieldInputWithContextProps) => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
useEffect(() => {
@@ -108,7 +108,7 @@ const RelationToOneFieldInputWithContext = ({
<FieldInputEventContextProviderWithJestMocks>
<RelationWorkspaceSetterEffect />
<RelationToOneFieldInput />
<RelationManyToOneFieldInput />
</FieldInputEventContextProviderWithJestMocks>
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
@@ -126,8 +126,8 @@ const clearMocksDecorator: Decorator = (Story, context) => {
};
const meta: Meta = {
title: 'UI/Data/Field/Input/RelationToOneFieldInput',
component: RelationToOneFieldInputWithContext,
title: 'UI/Data/Field/Input/RelationManyToOneFieldInput',
component: RelationManyToOneFieldInputWithContext,
args: {
useEditButton: true,
onSubmit: handleSubmitMocked,
@@ -151,7 +151,7 @@ const meta: Meta = {
export default meta;
type Story = StoryObj<typeof RelationToOneFieldInputWithContext>;
type Story = StoryObj<typeof RelationManyToOneFieldInputWithContext>;
export const Default: Story = {};
@@ -4,7 +4,6 @@ import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { RelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RelationFromManyFieldInput';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
@@ -17,6 +16,7 @@ import {
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useOpenFieldInputEditMode } from '@/object-record/record-field/ui/hooks/useOpenFieldInputEditMode';
import { RelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/RelationOneToManyFieldInput';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
@@ -37,7 +37,7 @@ const RelationWorkspaceSetterEffect = () => {
return <></>;
};
const RelationManyFieldInputWithContext = () => {
const RelationOneToManyFieldInputWithContext = () => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const fieldDefinition = useMemo(
@@ -104,7 +104,7 @@ const RelationManyFieldInputWithContext = () => {
}}
>
<RelationWorkspaceSetterEffect />
<RelationFromManyFieldInput />
<RelationOneToManyFieldInput />
</FieldContext.Provider>
</RecordFieldComponentInstanceContext.Provider>
<div data-testid="data-field-input-click-outside-div" />
@@ -112,8 +112,8 @@ const RelationManyFieldInputWithContext = () => {
);
};
const meta: Meta = {
title: 'UI/Data/Field/Input/RelationManyFieldInput',
component: RelationManyFieldInputWithContext,
title: 'UI/Data/Field/Input/RelationOneToManyFieldInput',
component: RelationOneToManyFieldInputWithContext,
args: {},
decorators: [ObjectMetadataItemsDecorator, SnackBarDecorator],
parameters: {
@@ -124,6 +124,6 @@ const meta: Meta = {
export default meta;
type Story = StoryObj<typeof RelationManyFieldInputWithContext>;
type Story = StoryObj<typeof RelationOneToManyFieldInputWithContext>;
export const Default: Story = {};
@@ -0,0 +1,91 @@
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import {
type FieldMorphRelationMetadata,
type FieldRelationToOneValue,
type FieldRelationValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { useSingleRecordPickerOpen } from '@/object-record/record-picker/single-record-picker/hooks/useSingleRecordPickerOpen';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilCallback } from 'recoil';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export const useOpenMorphRelationManyToOneFieldInput = () => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { openSingleRecordPicker } = useSingleRecordPickerOpen();
const openMorphRelationManyToOneFieldInput = useRecoilCallback(
({ set, snapshot }) =>
({
fieldDefinition,
recordId,
prefix,
}: {
fieldDefinition: FieldDefinition<FieldMorphRelationMetadata>;
recordId: string;
prefix?: string;
}) => {
const potentielFieldNames = fieldDefinition.metadata.morphRelations.map(
(morphRelation) => {
return computeMorphRelationFieldName({
fieldName: fieldDefinition.metadata.fieldName,
relationType: fieldDefinition.metadata.relationType,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
});
},
);
const fieldValue = potentielFieldNames
.map((fieldName) => {
const fieldValue = snapshot
.getLoadable<FieldRelationValue<FieldRelationToOneValue>>(
recordStoreFamilySelector({
recordId,
fieldName,
}),
)
.getValue();
return fieldValue;
})
.find((fieldValue) => isDefined(fieldValue));
const recordPickerInstanceId = getRecordFieldInputInstanceId({
recordId,
fieldName: fieldDefinition.metadata.fieldName,
prefix,
});
if (isDefined(fieldValue)) {
set(
singleRecordPickerSelectedIdComponentState.atomFamily({
instanceId: recordPickerInstanceId,
}),
fieldValue.id,
);
}
openSingleRecordPicker(recordPickerInstanceId);
pushFocusItemToFocusStack({
focusId: recordPickerInstanceId,
component: {
type: FocusComponentType.OPENED_FIELD_INPUT,
instanceId: recordPickerInstanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
},
[openSingleRecordPicker, pushFocusItemToFocusStack],
);
return { openMorphRelationManyToOneFieldInput };
};
@@ -16,13 +16,13 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useOpenMorphRelationFromManyFieldInput = () => {
export const useOpenMorphRelationOneToManyFieldInput = () => {
const { performSearch } = useMultipleRecordPickerPerformSearch();
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const openMorphRelationFromManyFieldInput = useRecoilCallback(
const openMorphRelationOneToManyFieldInput = useRecoilCallback(
({ set, snapshot }) =>
({
recordId,
@@ -48,7 +48,6 @@ export const useOpenMorphRelationFromManyFieldInput = () => {
recordStoreMorphOneToManyValueWithObjectNameFamilySelector({
recordId,
morphRelations: fieldDefinition.metadata.morphRelations,
fieldName: fieldDefinition.metadata.fieldName,
}),
)
.getValue();
@@ -136,5 +135,7 @@ export const useOpenMorphRelationFromManyFieldInput = () => {
[openMultipleRecordPicker, performSearch, pushFocusItemToFocusStack],
);
return { openMorphRelationFromManyFieldInput };
return {
openMorphRelationOneToManyFieldInput,
};
};
@@ -1,66 +0,0 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { useAttachMorphRelatedRecordFromRecord } from '@/object-record/hooks/useAttachMorphRelatedRecordFromRecord';
import { useDetachMorphRelatedRecordFromRecord } from '@/object-record/hooks/useDetachMorphRelatedRecordFromRecord';
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 { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useUpdateMorphRelationManyToOneFieldInput = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
if (!fieldDefinition.metadata.objectMetadataNameSingular) {
throw new Error('ObjectMetadataNameSingular is required');
}
const { updateOneRecordAndDetachMorphRelations } =
useDetachMorphRelatedRecordFromRecord();
const { updateOneRecordAndAttachMorphRelations } =
useAttachMorphRelatedRecordFromRecord();
const updateMorphRelationManyToOne = useRecoilCallback(
() =>
async (
morphItem: Pick<
RecordPickerPickableMorphItem,
'recordId' | 'isSelected'
>,
) => {
const recordObjectNameSingulars =
fieldDefinition.metadata.morphRelations.map(
(morphRelation) => morphRelation.targetObjectMetadata.nameSingular,
);
if (morphItem.isSelected) {
await updateOneRecordAndAttachMorphRelations({
recordId,
relatedRecordId: morphItem.recordId,
objectNameSingulars: recordObjectNameSingulars,
});
} else {
await updateOneRecordAndDetachMorphRelations({
recordId,
relatedRecordId: morphItem.recordId,
objectNameSingulars: recordObjectNameSingulars,
});
}
},
[
fieldDefinition.metadata.morphRelations,
recordId,
updateOneRecordAndAttachMorphRelations,
updateOneRecordAndDetachMorphRelations,
],
);
return { updateMorphRelationManyToOne };
};
@@ -0,0 +1,87 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { useRecordOneToManyFieldAttachTargetRecord } from '@/object-record/hooks/useRecordOneToManyFieldAttachTargetRecord';
import { useRecordOneToManyFieldDetachTargetRecord } from '@/object-record/hooks/useRecordOneToManyFieldDetachTargetRecord';
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 { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useUpdateMorphRelationOneToManyFieldInput = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.MORPH_RELATION,
isFieldMorphRelation,
fieldDefinition,
);
if (!fieldDefinition.metadata.objectMetadataNameSingular) {
throw new Error('ObjectMetadataNameSingular is required');
}
const { recordOneToManyFieldDetachTargetRecord } =
useRecordOneToManyFieldDetachTargetRecord();
const { recordOneToManyFieldAttachTargetRecord } =
useRecordOneToManyFieldAttachTargetRecord();
const updateMorphRelationOneToMany = useRecoilCallback(
() =>
async (
morphItem: Pick<
RecordPickerPickableMorphItem,
'recordId' | 'isSelected' | 'objectMetadataId'
>,
) => {
if (!fieldDefinition.metadata.objectMetadataNameSingular) {
throw new Error('ObjectMetadataNameSingular is required');
}
const targetMorphRelation =
fieldDefinition.metadata.morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.id ===
morphItem.objectMetadataId,
);
if (!isDefined(targetMorphRelation)) {
throw new Error('TargetMorphRelation is required');
}
if (morphItem.isSelected) {
await recordOneToManyFieldAttachTargetRecord({
sourceObjectNameSingular:
fieldDefinition.metadata.objectMetadataNameSingular,
targetObjectNameSingular:
targetMorphRelation.targetObjectMetadata.nameSingular,
targetGQLFieldName: targetMorphRelation.targetFieldMetadata.name,
sourceRecordId: recordId,
targetRecordId: morphItem.recordId,
});
} else {
await recordOneToManyFieldDetachTargetRecord({
sourceObjectNameSingular:
fieldDefinition.metadata.objectMetadataNameSingular,
targetObjectNameSingular:
targetMorphRelation.targetObjectMetadata.nameSingular,
targetGQLFieldName: targetMorphRelation.targetFieldMetadata.name,
sourceRecordId: recordId,
targetRecordId: morphItem.recordId,
});
}
},
[
fieldDefinition.metadata.morphRelations,
fieldDefinition.metadata.objectMetadataNameSingular,
recordId,
recordOneToManyFieldAttachTargetRecord,
recordOneToManyFieldDetachTargetRecord,
],
);
return { updateMorphRelationOneToMany };
};
@@ -1,61 +0,0 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { useAttachRelatedRecordFromRecord } from '@/object-record/hooks/useAttachRelatedRecordFromRecord';
import { useDetachRelatedRecordFromRecord } from '@/object-record/hooks/useDetachRelatedRecordFromRecord';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useUpdateRelationFromManyFieldInput = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.RELATION,
isFieldRelation,
fieldDefinition,
);
if (!fieldDefinition.metadata.objectMetadataNameSingular) {
throw new Error('ObjectMetadataNameSingular is required');
}
const { updateOneRecordAndDetachRelations } =
useDetachRelatedRecordFromRecord({
recordObjectNameSingular:
fieldDefinition.metadata.objectMetadataNameSingular,
fieldNameOnRecordObject: fieldDefinition.metadata.fieldName,
});
const { updateOneRecordAndAttachRelations } =
useAttachRelatedRecordFromRecord({
recordObjectNameSingular:
fieldDefinition.metadata.objectMetadataNameSingular,
fieldNameOnRecordObject: fieldDefinition.metadata.fieldName,
});
const updateRelation = useRecoilCallback(
() => async (morphItem: RecordPickerPickableMorphItem) => {
if (morphItem.isSelected) {
await updateOneRecordAndAttachRelations({
recordId,
relatedRecordId: morphItem.recordId,
});
} else {
await updateOneRecordAndDetachRelations({
recordId,
relatedRecordId: morphItem.recordId,
});
}
},
[
recordId,
updateOneRecordAndAttachRelations,
updateOneRecordAndDetachRelations,
],
);
return { updateRelation };
};
@@ -0,0 +1,123 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useRecordOneToManyFieldAttachTargetRecord } from '@/object-record/hooks/useRecordOneToManyFieldAttachTargetRecord';
import { useRecordOneToManyFieldDetachTargetRecord } from '@/object-record/hooks/useRecordOneToManyFieldDetachTargetRecord';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useUpdateRelationOneToManyFieldInput = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.RELATION,
isFieldRelation,
fieldDefinition,
);
const { objectMetadataItem: targetObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
});
if (!isDefined(fieldDefinition.metadata.objectMetadataNameSingular)) {
throw new Error('ObjectMetadataNameSingular is required');
}
const { objectMetadataItem: sourceObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: fieldDefinition.metadata.objectMetadataNameSingular,
});
if (!fieldDefinition.metadata.objectMetadataNameSingular) {
throw new Error('ObjectMetadataNameSingular is required');
}
if (!isDefined(targetObjectMetadataItem)) {
throw new Error('TargetObjectMetadataItem is required');
}
const { recordOneToManyFieldDetachTargetRecord } =
useRecordOneToManyFieldDetachTargetRecord();
const { recordOneToManyFieldAttachTargetRecord } =
useRecordOneToManyFieldAttachTargetRecord();
const updateRelation = useRecoilCallback(
() => async (morphItem: RecordPickerPickableMorphItem) => {
if (
!fieldDefinition.metadata?.relationObjectMetadataNameSingular ||
!fieldDefinition.metadata?.targetFieldMetadataName
) {
throw new Error('RelationObjectMetadataNameSingular is required');
}
if (!fieldDefinition.metadata?.objectMetadataNameSingular) {
throw new Error('RelationFieldMetadata is required');
}
const targetFieldMetadata = targetObjectMetadataItem.fields.find(
(field) =>
field.name === fieldDefinition.metadata.targetFieldMetadataName,
);
if (!isDefined(targetFieldMetadata)) {
throw new Error('TargetFieldMetadata is required');
}
let targetGQLFieldName: string;
if (targetFieldMetadata.type === FieldMetadataType.MORPH_RELATION) {
targetGQLFieldName = computeMorphRelationFieldName({
fieldName: fieldDefinition.metadata.targetFieldMetadataName,
relationType: targetFieldMetadata.settings?.relationType,
targetObjectMetadataNameSingular:
sourceObjectMetadataItem.nameSingular,
targetObjectMetadataNamePlural: sourceObjectMetadataItem.namePlural,
});
} else {
targetGQLFieldName = fieldDefinition.metadata.targetFieldMetadataName;
}
if (morphItem.isSelected) {
await recordOneToManyFieldAttachTargetRecord({
sourceObjectNameSingular:
fieldDefinition.metadata.objectMetadataNameSingular,
targetObjectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
targetGQLFieldName,
sourceRecordId: recordId,
targetRecordId: morphItem.recordId,
});
} else {
await recordOneToManyFieldDetachTargetRecord({
sourceObjectNameSingular:
fieldDefinition.metadata.objectMetadataNameSingular,
targetObjectNameSingular:
fieldDefinition.metadata.relationObjectMetadataNameSingular,
targetGQLFieldName,
sourceRecordId: recordId,
targetRecordId: morphItem.recordId,
});
}
},
[
fieldDefinition.metadata.objectMetadataNameSingular,
fieldDefinition.metadata.relationObjectMetadataNameSingular,
fieldDefinition.metadata.targetFieldMetadataName,
recordId,
recordOneToManyFieldAttachTargetRecord,
recordOneToManyFieldDetachTargetRecord,
sourceObjectMetadataItem.namePlural,
sourceObjectMetadataItem.nameSingular,
targetObjectMetadataItem.fields,
],
);
return { updateRelation };
};
@@ -7,7 +7,7 @@ import {
type FieldRelationMetadata,
} from '../FieldMetadata';
export const isFieldRelationToOneObject = (
export const isFieldRelationManyToOne = (
field: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldRelationMetadata> =>
isFieldRelation(field) &&
@@ -3,7 +3,7 @@ import { isNull, isObject, isUndefined } from '@sniptt/guards';
import { type FieldRelationToOneValue } from '@/object-record/record-field/ui/types/FieldMetadata';
// TODO: add zod
export const isFieldRelationToOneValue = (
export const isFieldRelationManyToOneValue = (
fieldValue: unknown,
): fieldValue is FieldRelationToOneValue =>
!isUndefined(fieldValue) && (isObject(fieldValue) || isNull(fieldValue));
@@ -7,7 +7,7 @@ import {
type FieldRelationMetadata,
} from '../FieldMetadata';
export const isFieldRelationFromManyObjects = (
export const isFieldRelationOneToMany = (
field: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>,
): field is FieldDefinition<FieldRelationMetadata> =>
isFieldRelation(field) &&
@@ -3,7 +3,7 @@ import { isNull, isObject, isUndefined } from '@sniptt/guards';
import { type FieldRelationFromManyValue } from '@/object-record/record-field/ui/types/FieldMetadata';
// TODO: add zod
export const isFieldRelationFromManyValue = (
export const isFieldRelationOneToManyValue = (
fieldValue: unknown,
): fieldValue is FieldRelationFromManyValue =>
!isUndefined(fieldValue) && (isObject(fieldValue) || isNull(fieldValue));
@@ -25,7 +25,7 @@ export const SingleRecordPicker = ({
onCancel,
onCreate,
onRecordSelected,
objectNameSingular,
objectNameSingulars,
componentInstanceId,
layoutDirection,
dropdownWidth,
@@ -84,7 +84,7 @@ export const SingleRecordPicker = ({
onCancel: handleCancel,
onCreate,
onRecordSelected: handleRecordSelected,
objectNameSingular,
objectNameSingulars,
layoutDirection,
}}
/>
@@ -1,5 +1,5 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { SingleRecordPickerLoadingEffect } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerLoadingEffect';
import {
SingleRecordPickerMenuItems,
@@ -22,7 +22,7 @@ import { IconPlus } from 'twenty-ui/display';
export type SingleRecordPickerMenuItemsWithSearchProps = {
excludedRecordIds?: string[];
onCreate?: ((searchInput?: string) => void) | (() => void);
objectNameSingular: string;
objectNameSingulars: string[];
recordPickerInstanceId?: string;
layoutDirection?: RecordPickerLayoutDirection;
focusId: string;
@@ -42,7 +42,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
onCancel,
onCreate,
onRecordSelected,
objectNameSingular,
objectNameSingulars,
layoutDirection = 'search-bar-on-top',
focusId,
}: SingleRecordPickerMenuItemsWithSearchProps) => {
@@ -58,19 +58,24 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
);
const { records } = useSingleRecordPickerRecords({
objectNameSingulars: [objectNameSingular],
objectNameSingulars,
excludedRecordIds,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
const { objectMetadataItems: allObjectMetadataItems } =
useObjectMetadataItems();
const objectMetadataItems = allObjectMetadataItems.filter(
(objectMetadataItem) =>
objectNameSingulars.includes(objectMetadataItem.nameSingular),
);
const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords;
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const hasUpdatePermissions = objectMetadataItems.every(
(objectMetadataItem) =>
objectPermissionsByObjectMetadataId[objectMetadataItem.id]
?.canUpdateObjectRecords,
);
const handleCreateNew = () => {
onCreate?.(recordPickerSearchFilter);
@@ -81,7 +86,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
<SingleRecordPickerLoadingEffect loading={records.loading} />
{layoutDirection === 'search-bar-on-bottom' && (
<>
{isDefined(onCreate) && hasObjectUpdatePermissions && (
{isDefined(onCreate) && hasUpdatePermissions && (
<>
<DropdownMenuItemsContainer scrollable={false}>
<CreateNewButton
@@ -133,7 +138,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
}}
/>
</DropdownMenuItemsContainer>
{isDefined(onCreate) && hasObjectUpdatePermissions && (
{isDefined(onCreate) && hasUpdatePermissions && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
@@ -34,7 +34,7 @@ const meta: Meta<typeof SingleRecordPicker> = {
I18nFrontDecorator,
],
args: {
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
objectNameSingulars: [CoreObjectNameSingular.WorkspaceMember],
componentInstanceId: 'single-record-picker',
},
argTypes: {
@@ -3,24 +3,30 @@ 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 { isDefined } from 'twenty-shared/utils';
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 fieldName = computeMorphRelationFieldName({
fieldName: morphRelation.sourceFieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
});
return {
objectNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
@@ -3,23 +3,31 @@ 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 { type 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 fieldName = computeMorphRelationFieldName({
fieldName: morphRelation.sourceFieldMetadata.name,
relationType: morphRelation.type as RelationType,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
});
return {
objectNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
@@ -3,8 +3,8 @@ import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifie
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
import { type RecordField } from '@/object-record/record-field/types/RecordField';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/ui/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/ui/types/guards/isFieldRelationToOneObject';
import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { RecordUpdateContext } from '@/object-record/record-table/contexts/EntityUpdateMutationHookContext';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
@@ -39,8 +39,8 @@ export const RecordTableCellFieldContextGeneric = ({
// todo @guillim : adjust this to handle morph relations permissions display
if (
isFieldRelationToOneObject(fieldDefinition) ||
isFieldRelationFromManyObjects(fieldDefinition)
isFieldRelationManyToOne(fieldDefinition) ||
isFieldRelationOneToMany(fieldDefinition)
) {
const relationObjectMetadataId =
fieldDefinition.metadata.relationObjectMetadataId;
@@ -9,12 +9,13 @@ import {
import { GRAPHQL_TYPENAME_KEY } from '@/object-record/constants/GraphqlTypenameKey';
import { type FieldActorValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldActor } from '@/object-record/record-field/ui/types/guards/isFieldActor';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFieldUuid';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { buildOptimisticActorFieldValueFromCurrentWorkspaceMember } from '@/object-record/utils/buildOptimisticActorFieldValueFromCurrentWorkspaceMember';
import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
type ComputeOptimisticCacheRecordInputArgs = {
@@ -39,16 +40,34 @@ export const computeOptimisticRecordFromInput = ({
(field) => field.name === recordKey,
);
const potentialJoinColumnNameFieldMetadataItem =
const potentialRelationJoinColumnNameFieldMetadataItem =
objectMetadataItem.fields.find(
(field) =>
field.type === FieldMetadataType.RELATION &&
field.settings?.joinColumnName === recordKey,
);
const potentialMorphRelationJoinColumnNameFieldMetadataItem =
objectMetadataItem.fields.find((field) => {
if (!isFieldMorphRelation(field)) return false;
return field.morphRelations?.some((morphRelation) => {
const computedFieldName = computeMorphRelationFieldName({
fieldName: field.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
});
return computedFieldName === recordKey.replace('Id', '');
});
});
const isUnknownField =
!isDefined(correspondingFieldMetadataItem) &&
!isDefined(potentialJoinColumnNameFieldMetadataItem);
!isDefined(potentialRelationJoinColumnNameFieldMetadataItem) &&
!isDefined(potentialMorphRelationJoinColumnNameFieldMetadataItem);
const isTypenameField = recordKey === GRAPHQL_TYPENAME_KEY;
return isUnknownField && !isTypenameField;
@@ -1,7 +1,8 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isSystemSearchVectorField } from '@/object-record/utils/isSystemSearchVectorField';
import { isDefined } from 'twenty-shared/utils';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
export const sanitizeRecordInput = ({
@@ -27,10 +28,26 @@ export const sanitizeRecordInput = ({
field.type === FieldMetadataType.RELATION &&
field.settings?.joinColumnName === fieldName,
);
const potentialMorphRelationJoinColumnNameFieldMetadataItem =
objectMetadataItem.fields.find((field) => {
if (!isFieldMorphRelation(field)) return false;
return field.morphRelations?.some((morphRelation) => {
const computedFieldName = computeMorphRelationFieldName({
fieldName: field.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
});
return computedFieldName === fieldName.replace('Id', '');
});
});
if (
!isDefined(fieldMetadataItem) &&
!isDefined(potentialJoinColumnNameFieldMetadataItem)
!isDefined(potentialJoinColumnNameFieldMetadataItem) &&
!isDefined(potentialMorphRelationJoinColumnNameFieldMetadataItem)
) {
return undefined;
}