Morph-picker-MultiSelect-picker (#14113)
# 🚀 Multi-Select Morph Relations / One to Many Adds **multi-select picker support** for morph relations on the **One To Many** side, allowing users to select multiple related records from different object types. ### 🔑 Main Changes (TL;DR) - **New Component**: `MorphRelationOneToManyFieldInput` for multi-selection - **Enhanced Hooks**: `useAttachMorphRelatedRecordFromRecord` & `useDetachMorphRelatedRecordFromRecord` - **Better UX**: Optimistic updates, improved search, loading states - **24 files changed**: Mostly additions (1,277 lines added, 56 removed) **Summary**: This adds multi-select capability to morph relations with proper state management and optimistic updates. The changes are mostly additive and follow existing patterns. **TODO** : - optimistic rendering not working currently - single select picker (many to one)
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { objectMetadataItemsBySingularNameSelector } from '@/object-metadata/states/objectMetadataItemsBySingularNameSelector';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useDoObjectMetadataItemsExist = (
|
||||
objectNameSingulars: string[],
|
||||
) => {
|
||||
const objectMetadataItems = useRecoilValue(
|
||||
objectMetadataItemsBySingularNameSelector(objectNameSingulars),
|
||||
);
|
||||
|
||||
return objectMetadataItems.every((objectMetadataItem) =>
|
||||
isDefined(objectMetadataItem),
|
||||
);
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { selectorFamily } from 'recoil';
|
||||
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const objectMetadataItemsBySingularNameSelector = selectorFamily<
|
||||
ObjectMetadataItem[],
|
||||
string[]
|
||||
>({
|
||||
key: 'objectMetadataItemsSelector',
|
||||
get:
|
||||
(objectNameSingulars: string[]) =>
|
||||
({ get }) => {
|
||||
const objectMetadataItems = get(objectMetadataItemsState);
|
||||
|
||||
return objectNameSingulars
|
||||
.map(
|
||||
(objectNameSingular) =>
|
||||
objectMetadataItems.find(
|
||||
(objectMetadataItem) =>
|
||||
objectMetadataItem.nameSingular === objectNameSingular,
|
||||
) ?? null,
|
||||
)
|
||||
.filter(isDefined);
|
||||
},
|
||||
});
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
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 { computeMorphRelationFieldName, 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 computedFieldName = computeMorphRelationFieldName({
|
||||
fieldName: fieldDefinition.metadata.fieldName,
|
||||
relationDirection: fieldDefinition.metadata.relationType,
|
||||
nameSingular: relatedObjectMetadataItem.nameSingular,
|
||||
namePlural: relatedObjectMetadataItem.namePlural,
|
||||
});
|
||||
|
||||
const currentMorphFieldValue = parentRecord[computedFieldName];
|
||||
|
||||
const objectRecordFromCache = getRecordFromCache({
|
||||
objectMetadataItem: relatedObjectMetadataItem,
|
||||
recordId: relatedRecordId,
|
||||
cache: apolloCoreClient.cache,
|
||||
objectMetadataItems,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
});
|
||||
|
||||
if (!isDefined(objectRecordFromCache)) {
|
||||
return;
|
||||
}
|
||||
|
||||
set(recordStoreFamilyState(recordId), {
|
||||
...parentRecord,
|
||||
[computedFieldName]: [
|
||||
...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 };
|
||||
};
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
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 { computeMorphRelationFieldName, 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 computedFieldName = computeMorphRelationFieldName({
|
||||
fieldName: fieldDefinition.metadata.fieldName,
|
||||
relationDirection: fieldDefinition.metadata.relationType,
|
||||
nameSingular: relatedObjectMetadataItem.nameSingular,
|
||||
namePlural: relatedObjectMetadataItem.namePlural,
|
||||
});
|
||||
|
||||
const currentMorphFieldValue = parentRecord[
|
||||
computedFieldName
|
||||
] 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,
|
||||
[computedFieldName]: 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 };
|
||||
};
|
||||
+9
-14
@@ -1,8 +1,7 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { type ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
|
||||
import { useDoObjectMetadataItemsExist } from '@/object-metadata/hooks/useDoObjectMetadataItemsExist';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { type WatchQueryFetchPolicy } from '@apollo/client';
|
||||
import { useMemo } from 'react';
|
||||
@@ -14,7 +13,9 @@ import {
|
||||
} from '~/generated/graphql';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
export type UseSearchRecordsParams = ObjectMetadataItemIdentifier & {
|
||||
// maybe we should look at ObjectMetadataItemIdentifier to update the API even though there are many location to update
|
||||
export type UseSearchRecordsParams = {
|
||||
objectNameSingulars: string[];
|
||||
limit?: number;
|
||||
onError?: (error?: Error) => void;
|
||||
skip?: boolean;
|
||||
@@ -24,7 +25,7 @@ export type UseSearchRecordsParams = ObjectMetadataItemIdentifier & {
|
||||
};
|
||||
|
||||
export const useObjectRecordSearchRecords = ({
|
||||
objectNameSingular,
|
||||
objectNameSingulars,
|
||||
searchInput,
|
||||
limit,
|
||||
skip,
|
||||
@@ -32,30 +33,25 @@ export const useObjectRecordSearchRecords = ({
|
||||
fetchPolicy,
|
||||
}: UseSearchRecordsParams) => {
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
const areDefined = useDoObjectMetadataItemsExist(objectNameSingulars);
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const { data, loading, error, previousData } = useSearchQuery({
|
||||
skip:
|
||||
skip ||
|
||||
!objectMetadataItem ||
|
||||
!currentWorkspaceMember ||
|
||||
!isDefined(searchInput),
|
||||
skip || !areDefined || !currentWorkspaceMember || !isDefined(searchInput),
|
||||
variables: {
|
||||
searchInput: searchInput ?? '',
|
||||
limit: limit ?? MAX_SEARCH_RESULTS,
|
||||
filter: filter ?? {},
|
||||
includedObjectNameSingulars: [objectNameSingular],
|
||||
includedObjectNameSingulars: objectNameSingulars,
|
||||
},
|
||||
fetchPolicy: fetchPolicy,
|
||||
client: apolloCoreClient,
|
||||
onError: (error) => {
|
||||
logError(
|
||||
`useSearchRecords for "${objectMetadataItem.namePlural}" error : ` +
|
||||
`useSearchRecords for "${objectNameSingulars.join(', ')}" error : ` +
|
||||
error,
|
||||
);
|
||||
enqueueErrorSnackBar({
|
||||
@@ -72,7 +68,6 @@ export const useObjectRecordSearchRecords = ({
|
||||
);
|
||||
|
||||
return {
|
||||
objectMetadataItem,
|
||||
searchRecords,
|
||||
loading,
|
||||
error,
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CustomError } from '@/error-handler/CustomError';
|
||||
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 { 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,
|
||||
};
|
||||
};
|
||||
+8
-30
@@ -1,15 +1,11 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
|
||||
import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
|
||||
import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
|
||||
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
|
||||
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
||||
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
import { generateUpdateOneRecordMutation } from '@/object-record/multiple-objects/utils/generateUpdateOneRecordMutation';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
export const useUpdateOneRecordMutation = ({
|
||||
@@ -33,31 +29,13 @@ export const useUpdateOneRecordMutation = ({
|
||||
return { updateOneRecordMutation: EMPTY_MUTATION };
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
)}
|
||||
}
|
||||
`;
|
||||
const updateOneRecordMutation = generateUpdateOneRecordMutation({
|
||||
objectMetadataItem,
|
||||
objectMetadataItems,
|
||||
recordGqlFields,
|
||||
computeReferences,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
});
|
||||
|
||||
return {
|
||||
updateOneRecordMutation,
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
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
@@ -14,6 +14,7 @@ import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFi
|
||||
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 { MorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MorphRelationOneToManyFieldInput';
|
||||
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';
|
||||
@@ -24,6 +25,7 @@ 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 { 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';
|
||||
@@ -50,6 +52,8 @@ export const FieldInput = () => {
|
||||
<RelationToOneFieldInput />
|
||||
) : isFieldRelationFromManyObjects(fieldDefinition) ? (
|
||||
<RelationFromManyFieldInput />
|
||||
) : isFieldMorphRelationOneToMany(fieldDefinition) ? (
|
||||
<MorphRelationOneToManyFieldInput />
|
||||
) : isFieldPhones(fieldDefinition) ? (
|
||||
<PhonesFieldInput />
|
||||
) : isFieldText(fieldDefinition) ? (
|
||||
|
||||
+20
@@ -5,6 +5,7 @@ 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 { 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';
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
type FieldRelationFromManyValue,
|
||||
type FieldRelationValue,
|
||||
} from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
|
||||
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 { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
@@ -29,6 +32,9 @@ export const useOpenFieldInputEditMode = () => {
|
||||
const { openRelationFromManyFieldInput } =
|
||||
useOpenRelationFromManyFieldInput();
|
||||
|
||||
const { openMorphRelationFromManyFieldInput } =
|
||||
useOpenMorphRelationFromManyFieldInput();
|
||||
|
||||
const { openActivityTargetCellEditMode } =
|
||||
useOpenActivityTargetCellEditMode();
|
||||
|
||||
@@ -95,6 +101,19 @@ export const useOpenFieldInputEditMode = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFieldMorphRelationOneToMany(fieldDefinition)) {
|
||||
if (!isFieldMorphRelation(fieldDefinition)) {
|
||||
throw new Error('Field is not a morph relation one to many');
|
||||
}
|
||||
|
||||
openMorphRelationFromManyFieldInput({
|
||||
recordId,
|
||||
prefix,
|
||||
fieldDefinition,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFieldRelationFromManyObjects(fieldDefinition)) {
|
||||
if (
|
||||
isDefined(
|
||||
@@ -133,6 +152,7 @@ export const useOpenFieldInputEditMode = () => {
|
||||
},
|
||||
[
|
||||
openActivityTargetCellEditMode,
|
||||
openMorphRelationFromManyFieldInput,
|
||||
openRelationFromManyFieldInput,
|
||||
openRelationToOneFieldInput,
|
||||
pushFocusItemToFocusStack,
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { useContext } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { useGetButtonIcon } from '@/object-record/record-field/ui/hooks/useGetButtonIcon';
|
||||
import { type FieldRelationValue } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
|
||||
import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/ui/states/recordFieldInputDraftValueComponentState';
|
||||
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
|
||||
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
|
||||
export const useMorphRelationField = <
|
||||
T extends ObjectRecord | ObjectRecord[],
|
||||
>() => {
|
||||
const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext);
|
||||
const button = useGetButtonIcon();
|
||||
|
||||
assertFieldMetadata(
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
isFieldMorphRelation,
|
||||
fieldDefinition,
|
||||
);
|
||||
|
||||
const fieldName = fieldDefinition.metadata.fieldName;
|
||||
|
||||
const [fieldValue, setFieldValue] = useRecoilState<FieldRelationValue<T>>(
|
||||
recordStoreFamilySelector({ recordId, fieldName }),
|
||||
);
|
||||
|
||||
const draftValue = useRecoilComponentValue(
|
||||
recordFieldInputDraftValueComponentState,
|
||||
);
|
||||
|
||||
const initialSearchValue = draftValue;
|
||||
|
||||
return {
|
||||
fieldDefinition,
|
||||
fieldValue,
|
||||
initialSearchValue,
|
||||
setFieldValue,
|
||||
maxWidth: button && maxWidth ? maxWidth - 28 : maxWidth,
|
||||
recordId,
|
||||
};
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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 { 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';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
|
||||
export const MorphRelationOneToManyFieldInput = () => {
|
||||
const instanceId = useAvailableComponentInstanceIdOrThrow(
|
||||
RecordFieldComponentInstanceContext,
|
||||
);
|
||||
|
||||
const { onSubmit } = useContext(FieldInputEventContext);
|
||||
|
||||
const { updateMorphRelationManyToOne } =
|
||||
useUpdateMorphRelationManyToOneFieldInput();
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit?.({ skipPersist: true });
|
||||
};
|
||||
|
||||
const layoutDirection = useRecoilComponentValue(
|
||||
recordFieldInputLayoutDirectionComponentState,
|
||||
);
|
||||
|
||||
return (
|
||||
<MultipleRecordPicker
|
||||
focusId={instanceId}
|
||||
componentInstanceId={instanceId}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={(morphItem) => {
|
||||
updateMorphRelationManyToOne(morphItem);
|
||||
}}
|
||||
onClickOutside={handleSubmit}
|
||||
layoutDirection={
|
||||
layoutDirection === 'downward'
|
||||
? 'search-bar-on-top'
|
||||
: 'search-bar-on-bottom'
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
|
||||
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
|
||||
import { type FieldMorphRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
|
||||
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
|
||||
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
|
||||
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
|
||||
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
|
||||
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { recordStoreMorphOneToManyValueWithObjectNameFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreMorphOneToManyValueWithObjectNameFamilySelector';
|
||||
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 { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useOpenMorphRelationFromManyFieldInput = () => {
|
||||
const { performSearch } = useMultipleRecordPickerPerformSearch();
|
||||
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
|
||||
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
|
||||
const openMorphRelationFromManyFieldInput = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
({
|
||||
recordId,
|
||||
prefix,
|
||||
fieldDefinition,
|
||||
}: {
|
||||
recordId: string;
|
||||
prefix?: string;
|
||||
fieldDefinition: FieldDefinition<FieldMorphRelationMetadata>;
|
||||
}) => {
|
||||
const recordPickerInstanceId = getRecordFieldInputInstanceId({
|
||||
recordId,
|
||||
fieldName: fieldDefinition.metadata.fieldName,
|
||||
prefix,
|
||||
});
|
||||
|
||||
const objectNameSingulars = fieldDefinition.metadata.morphRelations.map(
|
||||
({ targetObjectMetadata }) => targetObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
const morphValuesWithObjectNameSingularFieldValue = snapshot
|
||||
.getLoadable(
|
||||
recordStoreMorphOneToManyValueWithObjectNameFamilySelector({
|
||||
recordId,
|
||||
morphRelations: fieldDefinition.metadata.morphRelations,
|
||||
fieldName: fieldDefinition.metadata.fieldName,
|
||||
}),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
const objectMetadataItems = snapshot
|
||||
.getLoadable(objectMetadataItemsState)
|
||||
.getValue();
|
||||
|
||||
const objectMetadataItemArray = objectMetadataItems.filter(
|
||||
(objectMetadataItem) =>
|
||||
objectNameSingulars.includes(objectMetadataItem.nameSingular),
|
||||
);
|
||||
|
||||
if (
|
||||
!isDefined(objectMetadataItemArray) ||
|
||||
objectMetadataItemArray.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
openMultipleRecordPicker(recordPickerInstanceId);
|
||||
|
||||
const pickableMorphItems: RecordPickerPickableMorphItem[] =
|
||||
morphValuesWithObjectNameSingularFieldValue.flatMap(
|
||||
(recordWithObjectNameSingular) => {
|
||||
const objectMetadataItem = objectMetadataItemArray.find(
|
||||
(objectMetadataItem) =>
|
||||
objectMetadataItem.nameSingular ===
|
||||
recordWithObjectNameSingular.objectNameSingular,
|
||||
);
|
||||
|
||||
if (!objectMetadataItem) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return recordWithObjectNameSingular.value.map((recordValue) => ({
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
recordId: recordValue.id,
|
||||
isSelected: true,
|
||||
isMatchingSearchFilter: true,
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
const records = morphValuesWithObjectNameSingularFieldValue.flatMap(
|
||||
(recordWithObjectNameSingular) => recordWithObjectNameSingular.value,
|
||||
);
|
||||
|
||||
for (const record of records) {
|
||||
set(recordStoreFamilyState(record.id), record);
|
||||
}
|
||||
|
||||
set(
|
||||
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
|
||||
instanceId: recordPickerInstanceId,
|
||||
}),
|
||||
pickableMorphItems,
|
||||
);
|
||||
|
||||
set(
|
||||
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
|
||||
{ instanceId: recordPickerInstanceId },
|
||||
),
|
||||
objectMetadataItemArray,
|
||||
);
|
||||
|
||||
performSearch({
|
||||
multipleRecordPickerInstanceId: recordPickerInstanceId,
|
||||
forceSearchFilter: '',
|
||||
forceSearchableObjectMetadataItems: objectMetadataItemArray,
|
||||
forcePickableMorphItems: pickableMorphItems,
|
||||
});
|
||||
|
||||
pushFocusItemToFocusStack({
|
||||
focusId: recordPickerInstanceId,
|
||||
component: {
|
||||
type: FocusComponentType.DROPDOWN,
|
||||
instanceId: recordPickerInstanceId,
|
||||
},
|
||||
globalHotkeysConfig: {
|
||||
enableGlobalHotkeysConflictingWithKeyboard: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
[openMultipleRecordPicker, performSearch, pushFocusItemToFocusStack],
|
||||
);
|
||||
|
||||
return { openMorphRelationFromManyFieldInput };
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
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 };
|
||||
};
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const SingleRecordPickerMenuItemsWithSearch = ({
|
||||
);
|
||||
|
||||
const { records } = useSingleRecordPickerRecords({
|
||||
objectNameSingular,
|
||||
objectNameSingulars: [objectNameSingular],
|
||||
excludedRecordIds,
|
||||
});
|
||||
|
||||
|
||||
+3
-4
@@ -4,10 +4,10 @@ import { useFilteredSearchRecordQuery } from '@/search/hooks/useFilteredSearchRe
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
|
||||
export const useSingleRecordPickerRecords = ({
|
||||
objectNameSingular,
|
||||
objectNameSingulars,
|
||||
excludedRecordIds = [],
|
||||
}: {
|
||||
objectNameSingular: string;
|
||||
objectNameSingulars: string[];
|
||||
excludedRecordIds?: string[];
|
||||
}) => {
|
||||
const recordPickerSearchFilter = useRecoilComponentValue(
|
||||
@@ -17,12 +17,11 @@ export const useSingleRecordPickerRecords = ({
|
||||
const selectedRecordId = useRecoilComponentValue(
|
||||
singleRecordPickerSelectedIdComponentState,
|
||||
);
|
||||
|
||||
const records = useFilteredSearchRecordQuery({
|
||||
searchFilter: recordPickerSearchFilter,
|
||||
selectedIds: selectedRecordId ? [selectedRecordId] : [],
|
||||
excludedRecordIds: excludedRecordIds,
|
||||
objectNameSingular,
|
||||
objectNameSingulars,
|
||||
});
|
||||
|
||||
return { records };
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ describe('useFilteredSearchRecordQuery', () => {
|
||||
selectedIds: ['1'],
|
||||
limit: 10,
|
||||
excludedRecordIds: ['2'],
|
||||
objectNameSingular: 'person',
|
||||
objectNameSingulars: ['person'],
|
||||
searchFilter: 'Entity',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -8,13 +8,13 @@ export const useFilteredSearchRecordQuery = ({
|
||||
selectedIds,
|
||||
limit,
|
||||
excludedRecordIds = [],
|
||||
objectNameSingular,
|
||||
objectNameSingulars,
|
||||
searchFilter,
|
||||
}: {
|
||||
selectedIds: string[];
|
||||
limit?: number;
|
||||
excludedRecordIds?: string[];
|
||||
objectNameSingular: string;
|
||||
objectNameSingulars: string[];
|
||||
searchFilter?: string;
|
||||
}): {
|
||||
selectedRecords: SingleRecordPickerRecord[];
|
||||
@@ -26,7 +26,7 @@ export const useFilteredSearchRecordQuery = ({
|
||||
|
||||
const { loading: selectedRecordsLoading, searchRecords: selectedRecords } =
|
||||
useObjectRecordSearchRecords({
|
||||
objectNameSingular,
|
||||
objectNameSingulars,
|
||||
filter: selectedIdsFilter,
|
||||
skip: !selectedIds.length,
|
||||
searchInput: '',
|
||||
@@ -36,7 +36,7 @@ export const useFilteredSearchRecordQuery = ({
|
||||
loading: filteredSelectedRecordsLoading,
|
||||
searchRecords: filteredSelectedRecords,
|
||||
} = useObjectRecordSearchRecords({
|
||||
objectNameSingular,
|
||||
objectNameSingulars,
|
||||
filter: selectedIdsFilter,
|
||||
skip: !selectedIds.length,
|
||||
searchInput: searchFilter,
|
||||
@@ -48,7 +48,7 @@ export const useFilteredSearchRecordQuery = ({
|
||||
: undefined;
|
||||
const { loading: recordsToSelectLoading, searchRecords: recordsToSelect } =
|
||||
useObjectRecordSearchRecords({
|
||||
objectNameSingular,
|
||||
objectNameSingulars,
|
||||
filter: notFilter,
|
||||
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
|
||||
searchInput: searchFilter,
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ export const SettingsRoleAssignmentWorkspaceMemberPickerDropdown = ({
|
||||
|
||||
const { loading, searchRecords: workspaceMembers } =
|
||||
useObjectRecordSearchRecords({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
|
||||
objectNameSingulars: [CoreObjectNameSingular.WorkspaceMember],
|
||||
searchInput: searchFilter,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user