Update store on data model mutation (#18684)

- enrich SSE events with relations
- remove queries from sse metadata events
- on sse event, manage store
- on object/field metadata changes, manage store

TODO left:
- fix other metadata items

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas Trompette
2026-03-17 19:32:19 +01:00
committed by GitHub
co-authored by github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> github-actions Charles Bochet
parent e62dfae741
commit 994215e0dc
35 changed files with 1399 additions and 925 deletions
File diff suppressed because one or more lines are too long
@@ -1,11 +1,14 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { type MetadataEntityKey } from '@/metadata-store/states/metadataStoreState';
import { type MetadataEntityTypeMap } from '@/metadata-store/types/MetadataEntityTypeMap';
import { mapAllMetadataNameToEntityKey } from '@/metadata-store/utils/mapAllMetadataNameToEntityKey';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type AnyMetadataEntity = MetadataEntityTypeMap[MetadataEntityKey];
export const MetadataStoreSSEEffect = () => {
const store = useStore();
const { addToDraft, removeFromDraft, applyChanges } = useMetadataStore();
useListenToMetadataOperationBrowserEvent({
onMetadataOperationBrowserEvent: (eventDetail) => {
@@ -15,12 +18,42 @@ export const MetadataStoreSSEEffect = () => {
return;
}
patchMetadataStoreFromSSEEvent({
store,
entityKey,
operation: eventDetail.operation,
updatedCollectionHash: eventDetail.updatedCollectionHash,
});
const collectionHash = eventDetail.updatedCollectionHash;
switch (eventDetail.operation.type) {
case 'create': {
addToDraft({
key: entityKey,
items: [
eventDetail.operation
.createdRecord as unknown as AnyMetadataEntity,
],
collectionHash,
});
break;
}
case 'update': {
addToDraft({
key: entityKey,
items: [
eventDetail.operation
.updatedRecord as unknown as AnyMetadataEntity,
],
collectionHash,
});
break;
}
case 'delete': {
removeFromDraft({
key: entityKey,
itemIds: [eventDetail.operation.deletedRecordId],
collectionHash,
});
break;
}
}
applyChanges();
},
});
@@ -6,7 +6,7 @@ import { useCallback } from 'react';
const MOCKED_COLLECTION_HASH = 'mocked';
export const useLoadMockedMinimalMetadata = () => {
const { updateDraft, applyChanges, resetMetadataStore } = useMetadataStore();
const { replaceDraft, applyChanges, resetMetadataStore } = useMetadataStore();
const loadMockedMinimalMetadata = useCallback(async () => {
resetMetadataStore();
@@ -25,9 +25,9 @@ export const useLoadMockedMinimalMetadata = () => {
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataItemWithRelated(generatedMockObjectMetadataItems);
updateDraft('objectMetadataItems', flatObjects, MOCKED_COLLECTION_HASH);
updateDraft('fieldMetadataItems', flatFields, MOCKED_COLLECTION_HASH);
updateDraft('indexMetadataItems', flatIndexes, MOCKED_COLLECTION_HASH);
replaceDraft('objectMetadataItems', flatObjects, MOCKED_COLLECTION_HASH);
replaceDraft('fieldMetadataItems', flatFields, MOCKED_COLLECTION_HASH);
replaceDraft('indexMetadataItems', flatIndexes, MOCKED_COLLECTION_HASH);
const {
flatViews,
@@ -39,26 +39,30 @@ export const useLoadMockedMinimalMetadata = () => {
flatViewFieldGroups,
} = splitViewWithRelated(mockedViews);
updateDraft('views', flatViews, MOCKED_COLLECTION_HASH);
updateDraft('viewFields', flatViewFields, MOCKED_COLLECTION_HASH);
updateDraft('viewFilters', flatViewFilters, MOCKED_COLLECTION_HASH);
updateDraft('viewSorts', flatViewSorts, MOCKED_COLLECTION_HASH);
updateDraft('viewGroups', flatViewGroups, MOCKED_COLLECTION_HASH);
updateDraft(
replaceDraft('views', flatViews, MOCKED_COLLECTION_HASH);
replaceDraft('viewFields', flatViewFields, MOCKED_COLLECTION_HASH);
replaceDraft('viewFilters', flatViewFilters, MOCKED_COLLECTION_HASH);
replaceDraft('viewSorts', flatViewSorts, MOCKED_COLLECTION_HASH);
replaceDraft('viewGroups', flatViewGroups, MOCKED_COLLECTION_HASH);
replaceDraft(
'viewFilterGroups',
flatViewFilterGroups,
MOCKED_COLLECTION_HASH,
);
updateDraft('viewFieldGroups', flatViewFieldGroups, MOCKED_COLLECTION_HASH);
replaceDraft(
'viewFieldGroups',
flatViewFieldGroups,
MOCKED_COLLECTION_HASH,
);
updateDraft(
replaceDraft(
'navigationMenuItems',
mockedNavigationMenuItems,
MOCKED_COLLECTION_HASH,
);
applyChanges();
}, [updateDraft, applyChanges, resetMetadataStore]);
}, [replaceDraft, applyChanges, resetMetadataStore]);
return { loadMockedMinimalMetadata };
};
@@ -54,7 +54,7 @@ const hasOverlap = (
export const useLoadStaleMetadataEntities = () => {
const client = useApolloClient();
const store = useStore();
const { updateDraft, applyChanges } = useMetadataStore();
const { replaceDraft, applyChanges } = useMetadataStore();
const loadStaleMetadataEntities = useCallback(
async (staleEntityKeys: MetadataEntityKey[]) => {
@@ -80,9 +80,9 @@ export const useLoadStaleMetadataEntities = () => {
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataItemWithRelated(compositeObjects);
updateDraft('objectMetadataItems', flatObjects);
updateDraft('fieldMetadataItems', flatFields);
updateDraft('indexMetadataItems', flatIndexes);
replaceDraft('objectMetadataItems', flatObjects);
replaceDraft('fieldMetadataItems', flatFields);
replaceDraft('indexMetadataItems', flatIndexes);
}),
);
}
@@ -116,13 +116,13 @@ export const useLoadStaleMetadataEntities = () => {
flatViewFieldGroups,
} = splitViewWithRelated(allViews);
updateDraft('views', flatViews);
updateDraft('viewFields', flatViewFields);
updateDraft('viewFilters', flatViewFilters);
updateDraft('viewSorts', flatViewSorts);
updateDraft('viewGroups', flatViewGroups);
updateDraft('viewFilterGroups', flatViewFilterGroups);
updateDraft('viewFieldGroups', flatViewFieldGroups);
replaceDraft('views', flatViews);
replaceDraft('viewFields', flatViewFields);
replaceDraft('viewFilters', flatViewFilters);
replaceDraft('viewSorts', flatViewSorts);
replaceDraft('viewGroups', flatViewGroups);
replaceDraft('viewFilterGroups', flatViewFilterGroups);
replaceDraft('viewFieldGroups', flatViewFieldGroups);
}),
);
}
@@ -148,9 +148,9 @@ export const useLoadStaleMetadataEntities = () => {
flatPageLayoutWidgets,
} = splitPageLayoutWithRelated(transformed);
updateDraft('pageLayouts', flatPageLayouts);
updateDraft('pageLayoutTabs', flatPageLayoutTabs);
updateDraft('pageLayoutWidgets', flatPageLayoutWidgets);
replaceDraft('pageLayouts', flatPageLayouts);
replaceDraft('pageLayoutTabs', flatPageLayoutTabs);
replaceDraft('pageLayoutWidgets', flatPageLayoutWidgets);
}),
);
}
@@ -171,7 +171,10 @@ export const useLoadStaleMetadataEntities = () => {
logicFunctionsState.atom,
result.data.findManyLogicFunctions,
);
updateDraft('logicFunctions', result.data.findManyLogicFunctions);
replaceDraft(
'logicFunctions',
result.data.findManyLogicFunctions,
);
}),
);
}
@@ -188,7 +191,7 @@ export const useLoadStaleMetadataEntities = () => {
return;
}
updateDraft(
replaceDraft(
'navigationMenuItems',
result.data.navigationMenuItems,
);
@@ -199,7 +202,7 @@ export const useLoadStaleMetadataEntities = () => {
await Promise.all(fetchPromises);
applyChanges();
},
[client, store, updateDraft, applyChanges],
[client, store, replaceDraft, applyChanges],
);
return { loadStaleMetadataEntities };
@@ -8,6 +8,7 @@ import {
import { type MetadataEntityTypeMap } from '@/metadata-store/types/MetadataEntityTypeMap';
import { useStore, type createStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type JotaiStore = ReturnType<typeof createStore>;
@@ -58,7 +59,7 @@ const changeMetadataEntityAsUpToDate = (
export const useMetadataStore = () => {
const store = useStore();
const updateDraft = useCallback(
const replaceDraft = useCallback(
<K extends MetadataEntityKey>(
key: K,
data: MetadataEntityTypeMap[K][],
@@ -123,9 +124,159 @@ export const useMetadataStore = () => {
return { hasPersistedAnyMetadataEntity };
}, [store]);
const addToDraft = useCallback(
<K extends MetadataEntityKey>({
key,
items,
collectionHash,
}: {
key: K;
items: MetadataEntityTypeMap[K][];
collectionHash?: string;
}) => {
if (items.length === 0) {
return;
}
const entry = store.get(metadataStoreState.atomFamily(key));
const baseItems = (
entry.status === 'draft-pending' ? entry.draft : entry.current
) as Array<{ id: string }>;
let newItems = [...baseItems];
for (const item of items) {
const newItem = item as unknown as { id: string };
const existingIndex = newItems.findIndex(
(existing) => existing.id === newItem.id,
);
if (existingIndex >= 0) {
if (!isDeeplyEqual(newItems[existingIndex], newItem)) {
newItems[existingIndex] = newItem;
}
} else {
newItems = [...newItems, newItem];
}
}
store.set(metadataStoreState.atomFamily(key), {
...entry,
draft: newItems,
status: 'draft-pending' as const,
...(collectionHash !== undefined
? { draftCollectionHash: collectionHash }
: {}),
});
},
[store],
);
const updateInDraft = useCallback(
<K extends MetadataEntityKey>(
key: K,
items: MetadataEntityTypeMap[K][],
collectionHash?: string,
) => {
if (items.length === 0) {
return;
}
const entry = store.get(metadataStoreState.atomFamily(key));
const baseItems = (
entry.status === 'draft-pending' ? entry.draft : entry.current
) as Array<{ id: string }>;
let hasChanged = false;
const newItems = baseItems.map((existing) => {
const updatedItem = (items as unknown as Array<{ id: string }>).find(
(item) => item.id === existing.id,
);
if (!isDefined(updatedItem)) {
return existing;
}
const merged = { ...existing, ...updatedItem };
if (isDeeplyEqual(existing, merged)) {
return existing;
}
hasChanged = true;
return merged;
});
if (!hasChanged && collectionHash === undefined) {
return;
}
store.set(metadataStoreState.atomFamily(key), {
...entry,
draft: hasChanged ? newItems : baseItems,
status: 'draft-pending' as const,
...(collectionHash !== undefined
? { draftCollectionHash: collectionHash }
: {}),
});
},
[store],
);
const removeFromDraft = useCallback(
<K extends MetadataEntityKey>({
key,
itemIds,
collectionHash,
}: {
key: K;
itemIds: string[];
collectionHash?: string;
}) => {
if (itemIds.length === 0) {
return;
}
const entry = store.get(metadataStoreState.atomFamily(key));
const baseItems = (
entry.status === 'draft-pending' ? entry.draft : entry.current
) as Array<{ id: string }>;
const idsToDelete = new Set(itemIds);
const newItems = baseItems.filter(
(existing) => !idsToDelete.has(existing.id),
);
if (
newItems.length === baseItems.length &&
collectionHash === undefined
) {
return;
}
store.set(metadataStoreState.atomFamily(key), {
...entry,
draft: newItems,
status: 'draft-pending' as const,
...(collectionHash !== undefined
? { draftCollectionHash: collectionHash }
: {}),
});
},
[store],
);
const reset = useCallback(() => {
resetMetadataStore(store);
}, [store]);
return { updateDraft, applyChanges, resetMetadataStore: reset };
return {
replaceDraft,
applyChanges,
addToDraft,
updateInDraft,
removeFromDraft,
resetMetadataStore: reset,
};
};
@@ -1,74 +0,0 @@
import {
metadataStoreState,
type MetadataEntityKey,
} from '@/metadata-store/states/metadataStoreState';
import { type createStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
type JotaiStore = ReturnType<typeof createStore>;
type SSEEventOperation =
| {
type: 'create';
createdRecord: Record<string, unknown>;
}
| {
type: 'update';
updatedRecord: Record<string, unknown>;
}
| {
type: 'delete';
deletedRecordId: string;
};
export const patchMetadataStoreFromSSEEvent = ({
store,
entityKey,
operation,
updatedCollectionHash,
}: {
store: JotaiStore;
entityKey: MetadataEntityKey;
operation: SSEEventOperation;
updatedCollectionHash?: string;
}) => {
const entry = store.get(metadataStoreState.atomFamily(entityKey));
const currentItems = entry.current as Array<{ id: string }>;
const hashUpdate = isDefined(updatedCollectionHash)
? { currentCollectionHash: updatedCollectionHash }
: {};
switch (operation.type) {
case 'create': {
store.set(metadataStoreState.atomFamily(entityKey), {
...entry,
...hashUpdate,
current: [...currentItems, operation.createdRecord],
});
break;
}
case 'update': {
store.set(metadataStoreState.atomFamily(entityKey), {
...entry,
...hashUpdate,
current: currentItems.map((item) =>
item.id === (operation.updatedRecord as { id: string }).id
? { ...item, ...operation.updatedRecord }
: item,
),
});
break;
}
case 'delete': {
store.set(metadataStoreState.atomFamily(entityKey), {
...entry,
...hashUpdate,
current: currentItems.filter(
(item) => item.id !== operation.deletedRecordId,
),
});
break;
}
}
};
@@ -1,12 +1,12 @@
import { useMutation } from '@apollo/client/react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useCreateNavigationMenuItem = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
@@ -14,7 +14,7 @@ export const useRemoveNavigationMenuItemByTargetRecordId = () => {
const store = useStore();
const apolloCoreClient = useApolloCoreClient();
const cache = apolloCoreClient.cache;
const { updateDraft, applyChanges } = useMetadataStore();
const { replaceDraft, applyChanges } = useMetadataStore();
const removeNavigationMenuItemsByTargetRecordIds = useCallback(
(targetRecordIds: string[]) => {
@@ -31,7 +31,7 @@ export const useRemoveNavigationMenuItemByTargetRecordId = () => {
!targetRecordIdsSet.has(item.targetRecordId),
);
updateDraft('navigationMenuItems', updatedNavigationMenuItems);
replaceDraft('navigationMenuItems', updatedNavigationMenuItems);
applyChanges();
cache.updateQuery(
@@ -48,7 +48,7 @@ export const useRemoveNavigationMenuItemByTargetRecordId = () => {
},
);
},
[cache, store, updateDraft, applyChanges],
[cache, store, replaceDraft, applyChanges],
);
return {
@@ -19,6 +19,71 @@ export const CREATE_ONE_OBJECT_METADATA_ITEM = gql`
labelIdentifierFieldMetadataId
imageIdentifierFieldMetadataId
isLabelSyncedWithName
fieldsList {
id
universalIdentifier
type
name
label
description
icon
isCustom
isActive
isSystem
isUIReadOnly
isNullable
isUnique
createdAt
updatedAt
defaultValue
options
settings
isLabelSyncedWithName
morphId
applicationId
relation {
type
sourceObjectMetadata {
id
nameSingular
namePlural
}
targetObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
targetFieldMetadata {
id
name
}
}
morphRelations {
type
sourceObjectMetadata {
id
nameSingular
namePlural
}
targetObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
targetFieldMetadata {
id
name
}
}
}
}
}
`;
@@ -46,6 +111,48 @@ export const CREATE_ONE_FIELD_METADATA_ITEM = gql`
object {
id
}
relation {
type
sourceObjectMetadata {
id
nameSingular
namePlural
}
targetObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
targetFieldMetadata {
id
name
}
}
morphRelations {
type
sourceObjectMetadata {
id
nameSingular
namePlural
}
targetObjectMetadata {
id
nameSingular
namePlural
}
sourceFieldMetadata {
id
name
}
targetFieldMetadata {
id
name
}
}
}
}
`;
@@ -1,5 +1,6 @@
import {
CreateOneObjectMetadataItemDocument,
FindManyNavigationMenuItemsDocument,
FindManyViewsDocument,
} from '~/generated-metadata/graphql';
@@ -7,6 +8,9 @@ export const query = CreateOneObjectMetadataItemDocument;
export const findManyViewsQuery = FindManyViewsDocument;
export const findManyNavigationMenuItemsQuery =
FindManyNavigationMenuItemsDocument;
export const variables = {
input: {
object: {
@@ -35,4 +39,6 @@ export const responseData = {
updatedAt: '',
labelIdentifierFieldMetadataId: '20202020-72ba-4e11-a36d-e17b544541e1',
imageIdentifierFieldMetadataId: '',
isLabelSyncedWithName: false,
fieldsList: [],
};
@@ -4,6 +4,7 @@ import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreat
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import {
findManyNavigationMenuItemsQuery,
findManyViewsQuery,
query,
responseData,
@@ -63,6 +64,17 @@ const mocks = [
},
})),
},
{
request: {
query: findManyNavigationMenuItemsQuery,
variables: {},
},
result: jest.fn(() => ({
data: {
navigationMenuItems: [],
},
})),
},
];
const Wrapper = getJestMetadataAndApolloMocksWrapper({
@@ -5,10 +5,13 @@ import {
} from '~/generated-metadata/graphql';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { CrudOperationType } from 'twenty-shared/types';
export const useCreateOneFieldMetadataItem = () => {
@@ -18,6 +21,7 @@ export const useCreateOneFieldMetadataItem = () => {
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { addToDraft, applyChanges } = useMetadataStore();
const createOneFieldMetadataItem = async (
input: CreateFieldInput,
@@ -35,6 +39,23 @@ export const useCreateOneFieldMetadataItem = () => {
},
});
const createdField = response.data?.createOneField;
if (isDefined(createdField)) {
const { __typename, object, ...fieldData } = createdField;
addToDraft({
key: 'fieldMetadataItems',
items: [
{
...fieldData,
objectMetadataId: object?.id ?? input.objectMetadataId,
} as FlatFieldMetadataItem,
],
});
applyChanges();
}
return {
status: 'successful',
response,
@@ -1,22 +1,32 @@
import { useMutation } from '@apollo/client/react';
import { useApolloClient, useMutation } from '@apollo/client/react';
import {
type CreateObjectInput,
CreateOneObjectMetadataItemDocument,
FindManyNavigationMenuItemsDocument,
FindManyViewsDocument,
} from '~/generated-metadata/graphql';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelated';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { CrudOperationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const useCreateOneObjectMetadataItem = () => {
const [createOneObjectMetadataItemMutation] = useMutation(
CreateOneObjectMetadataItemDocument,
);
const client = useApolloClient();
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { addToDraft, replaceDraft, applyChanges } = useMetadataStore();
const createOneObjectMetadataItem = async (
input: CreateObjectInput,
@@ -32,6 +42,73 @@ export const useCreateOneObjectMetadataItem = () => {
},
});
const createdObject = createdObjectMetadata.data?.createOneObject;
if (isDefined(createdObject)) {
const {
__typename: _objectTypename,
fieldsList,
...objectData
} = createdObject;
addToDraft({
key: 'objectMetadataItems',
items: [objectData as FlatObjectMetadataItem],
});
const flatFields = fieldsList.map((field) => {
const { __typename: _fieldTypename, ...fieldData } = field;
return {
...fieldData,
objectMetadataId: createdObject.id,
} as FlatFieldMetadataItem;
});
addToDraft({ key: 'fieldMetadataItems', items: flatFields });
applyChanges();
const [viewsResult, navItemsResult] = await Promise.all([
client.query({
query: FindManyViewsDocument,
variables: { objectMetadataId: createdObject.id },
fetchPolicy: 'network-only',
}),
client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
}),
]);
const fetchedViews = viewsResult.data?.getViews ?? [];
const {
flatViews,
flatViewFields,
flatViewFilters,
flatViewSorts,
flatViewGroups,
flatViewFilterGroups,
flatViewFieldGroups,
} = splitViewWithRelated(fetchedViews);
addToDraft({ key: 'views', items: flatViews });
addToDraft({ key: 'viewFields', items: flatViewFields });
addToDraft({ key: 'viewFilters', items: flatViewFilters });
addToDraft({ key: 'viewSorts', items: flatViewSorts });
addToDraft({ key: 'viewGroups', items: flatViewGroups });
addToDraft({ key: 'viewFilterGroups', items: flatViewFilterGroups });
addToDraft({ key: 'viewFieldGroups', items: flatViewFieldGroups });
replaceDraft(
'navigationMenuItems',
navItemsResult.data?.navigationMenuItems ?? [],
);
applyChanges();
}
return {
status: 'successful',
response: createdObjectMetadata,
@@ -3,6 +3,7 @@ import { DeleteOneFieldMetadataItemDocument } from '~/generated-metadata/graphql
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState';
import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState';
@@ -21,6 +22,7 @@ export const useDeleteOneFieldMetadataItem = () => {
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { removeFromDraft, applyChanges } = useMetadataStore();
const setRecordIndexGroupAggregateOperation = useSetAtomComponentState(
recordIndexGroupAggregateOperationComponentState,
@@ -63,7 +65,10 @@ export const useDeleteOneFieldMetadataItem = () => {
},
});
// TODO: see if we can remove this lin altogether
removeFromDraft({ key: 'fieldMetadataItems', itemIds: [idToDelete] });
applyChanges();
// TODO: see if we can remove this line altogether
await resetRecordIndexKanbanAggregateOperation(idToDelete);
return {
@@ -2,6 +2,7 @@ import { useMutation } from '@apollo/client/react';
import { DeleteOneObjectMetadataItemDocument } from '~/generated-metadata/graphql';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
@@ -15,6 +16,7 @@ export const useDeleteOneObjectMetadataItem = () => {
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { removeFromDraft, applyChanges } = useMetadataStore();
const deleteOneObjectMetadataItem = async (
idToDelete: string,
@@ -30,6 +32,9 @@ export const useDeleteOneObjectMetadataItem = () => {
},
});
removeFromDraft({ key: 'objectMetadataItems', itemIds: [idToDelete] });
applyChanges();
return {
status: 'successful',
response,
@@ -3,7 +3,7 @@ import { splitObjectMetadataItemWithRelated } from '@/metadata-store/utils/split
import { useCallback } from 'react';
export const useLoadMockedObjectMetadataItems = () => {
const { updateDraft, applyChanges } = useMetadataStore();
const { replaceDraft, applyChanges } = useMetadataStore();
const loadMockedObjectMetadataItems = useCallback(async () => {
const { generatedMockObjectMetadataItems } = await import(
@@ -13,11 +13,11 @@ export const useLoadMockedObjectMetadataItems = () => {
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataItemWithRelated(generatedMockObjectMetadataItems);
updateDraft('objectMetadataItems', flatObjects);
updateDraft('fieldMetadataItems', flatFields);
updateDraft('indexMetadataItems', flatIndexes);
replaceDraft('objectMetadataItems', flatObjects);
replaceDraft('fieldMetadataItems', flatFields);
replaceDraft('indexMetadataItems', flatIndexes);
applyChanges();
}, [updateDraft, applyChanges]);
}, [replaceDraft, applyChanges]);
return {
loadMockedObjectMetadataItems,
@@ -5,12 +5,15 @@ import {
} from '~/generated-metadata/graphql';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { type FlatFieldMetadataItem } from '@/metadata-store/types/FlatFieldMetadataItem';
import { lastFieldMetadataItemUpdateState } from '@/object-metadata/states/lastFieldMetadataItemUpdateState';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { CrudOperationType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
@@ -22,6 +25,7 @@ export const useUpdateOneFieldMetadataItem = () => {
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { updateInDraft, applyChanges } = useMetadataStore();
const setLastFieldMetadataItemUpdate = useSetAtomState(
lastFieldMetadataItemUpdateState,
@@ -58,6 +62,20 @@ export const useUpdateOneFieldMetadataItem = () => {
},
});
const updatedField = response.data?.updateOneField;
if (isDefined(updatedField)) {
const { __typename, object, ...fieldData } = updatedField;
updateInDraft('fieldMetadataItems', [
{
...fieldData,
objectMetadataId: object?.id ?? objectMetadataId,
} as FlatFieldMetadataItem,
]);
applyChanges();
}
setLastFieldMetadataItemUpdate({
fieldMetadataItemId: fieldMetadataIdToUpdate,
objectMetadataId,
@@ -5,10 +5,13 @@ import {
} from '~/generated-metadata/graphql';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { CrudOperationType } from 'twenty-shared/types';
// TODO: Slice the Apollo store synchronously in the update function instead of subscribing, so we can use update after read in the same function call
@@ -19,6 +22,7 @@ export const useUpdateOneObjectMetadataItem = () => {
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const { updateInDraft, applyChanges } = useMetadataStore();
const updateOneObjectMetadataItem = async ({
idToUpdate,
@@ -39,6 +43,17 @@ export const useUpdateOneObjectMetadataItem = () => {
},
});
const updatedObject = response.data?.updateOneObject;
if (isDefined(updatedObject)) {
const { __typename, ...objectData } = updatedObject;
updateInDraft('objectMetadataItems', [
objectData as FlatObjectMetadataItem,
]);
applyChanges();
}
return {
status: 'successful',
response,
@@ -53,7 +53,7 @@ const RelationFieldValueSetterEffect = () => {
'recordTableId',
);
const { updateDraft, applyChanges } = useMetadataStore();
const { replaceDraft, applyChanges } = useMetadataStore();
useEffect(() => {
setRecordStore(mockPerformance.entityValue);
@@ -74,14 +74,14 @@ const RelationFieldValueSetterEffect = () => {
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataItemWithRelated(generatedMockObjectMetadataItems);
updateDraft('objectMetadataItems', flatObjects);
updateDraft('fieldMetadataItems', flatFields);
updateDraft('indexMetadataItems', flatIndexes);
replaceDraft('objectMetadataItems', flatObjects);
replaceDraft('fieldMetadataItems', flatFields);
replaceDraft('indexMetadataItems', flatIndexes);
applyChanges();
}, [
setRecordStore,
setRelationRecordStore,
updateDraft,
replaceDraft,
applyChanges,
setCurrentRecordFields,
]);
@@ -20,19 +20,17 @@ export const ON_EVENT_SUBSCRIPTION = gql`
}
queryIds
}
metadataEventsWithQueryIds {
metadataEvent {
type
metadataName
recordId
properties {
updatedFields
before
after
diff
}
metadataEvents {
type
metadataName
recordId
updatedCollectionHash
properties {
updatedFields
before
after
diff
}
queryIds
}
}
}
@@ -4,7 +4,6 @@ import { useCallback } from 'react';
import {
type AllMetadataName,
type MetadataEvent,
type MetadataEventWithQueryIds,
} from '~/generated-metadata/graphql';
const groupSseMetadataEventsByMetadataName = (
@@ -27,13 +26,9 @@ export const useDispatchMetadataEventsFromSseToBrowserEvents = <
T extends Record<string, unknown>,
>() => {
const dispatchMetadataEventsFromSseToBrowserEvents = useCallback(
(metadataEventsWithQueryIds: MetadataEventWithQueryIds[]) => {
const sseMetadataEvents = metadataEventsWithQueryIds.map(
(item) => item.metadataEvent,
);
(metadataEvents: MetadataEvent[]) => {
const eventsByMetadataName =
groupSseMetadataEventsByMetadataName(sseMetadataEvents);
groupSseMetadataEventsByMetadataName(metadataEvents);
for (const [metadataName, events] of eventsByMetadataName) {
const metadataOperationBrowserEvents =
@@ -98,8 +98,7 @@ export const useTriggerEventStreamCreation = () => {
const objectRecordEventsWithQueryIds =
eventSubscription?.objectRecordEventsWithQueryIds ?? [];
const metadataEventsWithQueryIds =
eventSubscription?.metadataEventsWithQueryIds ?? [];
const metadataEvents = eventSubscription?.metadataEvents ?? [];
const objectRecordEvents = objectRecordEventsWithQueryIds.map(
(item) => item.objectRecordEvent,
@@ -113,9 +112,7 @@ export const useTriggerEventStreamCreation = () => {
objectRecordEventsWithQueryIds,
);
dispatchMetadataEventsFromSseToBrowserEvents(
metadataEventsWithQueryIds,
);
dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents);
},
error: (error) => {
captureException(error);
@@ -169,6 +166,11 @@ export const useTriggerEventStreamCreation = () => {
dispatchObjectRecordEventsFromSseToBrowserEvents(
objectRecordEventsWithQueryIds,
);
const metadataEvents =
result?.data?.onEventSubscription?.metadataEvents ?? [];
dispatchMetadataEventsFromSseToBrowserEvents(metadataEvents);
}
}
} catch (error) {
@@ -12,7 +12,7 @@ export const JestObjectMetadataItemSetter = ({
children: ReactNode;
objectMetadataItems?: ObjectMetadataItem[];
}) => {
const { updateDraft, applyChanges } = useMetadataStore();
const { replaceDraft, applyChanges } = useMetadataStore();
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
@@ -20,12 +20,12 @@ export const JestObjectMetadataItemSetter = ({
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataItemWithRelated(items);
updateDraft('objectMetadataItems', flatObjects);
updateDraft('fieldMetadataItems', flatFields);
updateDraft('indexMetadataItems', flatIndexes);
replaceDraft('objectMetadataItems', flatObjects);
replaceDraft('fieldMetadataItems', flatFields);
replaceDraft('indexMetadataItems', flatIndexes);
applyChanges();
setIsLoaded(true);
}, [objectMetadataItems, updateDraft, applyChanges]);
}, [objectMetadataItems, replaceDraft, applyChanges]);
return isLoaded ? <>{children}</> : null;
};
@@ -1282,15 +1282,10 @@ type ObjectRecordEventWithQueryIds {
objectRecordEvent: ObjectRecordEvent!
}
type MetadataEventWithQueryIds {
queryIds: [String!]!
metadataEvent: MetadataEvent!
}
type EventSubscription {
eventStreamId: String!
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
metadataEventsWithQueryIds: [MetadataEventWithQueryIds!]!
metadataEvents: [MetadataEvent!]!
}
type OnDbEvent {
@@ -1004,16 +1004,10 @@ export interface ObjectRecordEventWithQueryIds {
__typename: 'ObjectRecordEventWithQueryIds'
}
export interface MetadataEventWithQueryIds {
queryIds: Scalars['String'][]
metadataEvent: MetadataEvent
__typename: 'MetadataEventWithQueryIds'
}
export interface EventSubscription {
eventStreamId: Scalars['String']
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
metadataEventsWithQueryIds: MetadataEventWithQueryIds[]
metadataEvents: MetadataEvent[]
__typename: 'EventSubscription'
}
@@ -3941,17 +3935,10 @@ export interface ObjectRecordEventWithQueryIdsGenqlSelection{
__scalar?: boolean | number
}
export interface MetadataEventWithQueryIdsGenqlSelection{
queryIds?: boolean | number
metadataEvent?: MetadataEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventSubscriptionGenqlSelection{
eventStreamId?: boolean | number
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
metadataEventsWithQueryIds?: MetadataEventWithQueryIdsGenqlSelection
metadataEvents?: MetadataEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -6869,14 +6856,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const MetadataEventWithQueryIds_possibleTypes: string[] = ['MetadataEventWithQueryIds']
export const isMetadataEventWithQueryIds = (obj?: { __typename?: any } | null): obj is MetadataEventWithQueryIds => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEventWithQueryIds"')
return MetadataEventWithQueryIds_possibleTypes.includes(obj.__typename)
}
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
File diff suppressed because it is too large Load Diff
+12
View File
@@ -171,6 +171,18 @@
}
}
},
"lint:diff-with-main": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "FILES=$(git diff --name-only --relative --diff-filter=d main...HEAD -- src/ | grep -E '\\.(ts|tsx)$'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --type-aware -c .oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main twenty-server --configuration=fix' && false)))"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --relative --diff-filter=d main...HEAD -- src/ | grep -E '\\.(ts|tsx)$'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --type-aware --fix -c .oxlintrc.json $FILES && prettier --write $FILES)"
}
}
},
"test": {},
"test:debug": {
"executor": "nx:run-commands",
@@ -10,7 +10,6 @@ import { type IndexMetadataInterface } from 'src/engine/metadata-modules/index-m
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { filterMorphRelationDuplicateFields } from 'src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util';
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
import { FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/field-metadata/constants/field-metadata-standard-overrides-properties.constant';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
@@ -21,11 +20,10 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findAllOthersMorphRelationFlatFieldMetadatasOrThrow } from 'src/engine/metadata-modules/flat-field-metadata/utils/find-all-others-morph-relation-flat-field-metadatas-or-throw.util';
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
import { fromMorphOrRelationFlatFieldMetadataToRelationDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-morph-or-relation-flat-field-metadata-to-relation-dto.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { resolveMorphRelationsFromFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/resolve-morph-relations-from-flat-field-metadata.util';
import { resolveRelationFromFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/resolve-relation-from-flat-field-metadata.util';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util';
import { fromFlatViewFieldGroupToViewFieldGroupDto } from 'src/engine/metadata-modules/view-field-group/utils/from-flat-view-field-group-to-view-field-group-dto.util';
@@ -158,7 +156,6 @@ export class DataloaderService {
private createRelationLoader() {
return new DataLoader<RelationLoaderPayload, RelationDTO | null>(
async (dataLoaderParams: RelationLoaderPayload[]) => {
const relationDtos: Array<RelationDTO | null> = [];
const workspaceId = dataLoaderParams[0].workspaceId;
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
@@ -168,7 +165,7 @@ export class DataloaderService {
},
);
for (const { fieldMetadataId } of dataLoaderParams) {
return dataLoaderParams.map(({ fieldMetadataId }) => {
const sourceFlatFieldMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: fieldMetadataId,
@@ -181,85 +178,15 @@ export class DataloaderService {
FieldMetadataType.RELATION,
)
) {
relationDtos.push(null);
continue;
return null;
}
const sourceFlatObjectMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: sourceFlatFieldMetadata.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const targetFlatFieldMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId:
sourceFlatFieldMetadata.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isMorphOrRelationFlatFieldMetadata(targetFlatFieldMetadata)) {
relationDtos.push(null);
continue;
}
const targetFlatObjectMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: targetFlatFieldMetadata.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (
isFlatFieldMetadataOfType(
targetFlatFieldMetadata,
FieldMetadataType.MORPH_RELATION,
)
) {
const morphNameFromMorphFieldMetadataName =
getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata: targetFlatFieldMetadata,
nameSingular: sourceFlatObjectMetadata.nameSingular,
namePlural: sourceFlatObjectMetadata.namePlural,
});
const allMorphFlatFieldMetadatas = [
targetFlatFieldMetadata,
...findAllOthersMorphRelationFlatFieldMetadatasOrThrow({
flatFieldMetadata: targetFlatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadata: targetFlatObjectMetadata,
}),
];
const survivorMorphField = pickMorphGroupSurvivor(
allMorphFlatFieldMetadatas,
);
relationDtos.push(
fromMorphOrRelationFlatFieldMetadataToRelationDto({
sourceFlatFieldMetadata,
sourceFlatObjectMetadata,
targetFlatFieldMetadata: {
...survivorMorphField,
name: morphNameFromMorphFieldMetadataName,
},
targetFlatObjectMetadata,
}),
);
continue;
}
relationDtos.push(
fromMorphOrRelationFlatFieldMetadataToRelationDto({
sourceFlatFieldMetadata,
targetFlatFieldMetadata,
targetFlatObjectMetadata,
sourceFlatObjectMetadata,
}),
);
}
return relationDtos;
return resolveRelationFromFlatFieldMetadata({
sourceFlatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
});
},
);
}
@@ -275,9 +202,8 @@ export class DataloaderService {
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const relationDtos: Array<RelationDTO[] | null> = [];
for (const { fieldMetadataId } of dataLoaderParams) {
return dataLoaderParams.map(({ fieldMetadataId }) => {
const morphFlatFieldMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: fieldMetadataId,
@@ -290,70 +216,15 @@ export class DataloaderService {
FieldMetadataType.MORPH_RELATION,
)
) {
relationDtos.push(null);
continue;
return null;
}
const sourceFlatObjectMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: morphFlatFieldMetadata.objectMetadataId,
});
const relatedMorphFlatFieldMetadatas =
findAllOthersMorphRelationFlatFieldMetadatasOrThrow({
flatFieldMetadata: morphFlatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadata: sourceFlatObjectMetadata,
});
const allMorphFlatFieldMetadatas = [
return resolveMorphRelationsFromFlatFieldMetadata({
morphFlatFieldMetadata,
...relatedMorphFlatFieldMetadatas,
];
relationDtos.push(
allMorphFlatFieldMetadatas.flatMap((sourceFlatFieldMetadata) => {
const targetFlatFieldMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId:
sourceFlatFieldMetadata.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (
!isMorphOrRelationFlatFieldMetadata(targetFlatFieldMetadata)
) {
return [];
}
const targetFlatObjectMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId:
sourceFlatFieldMetadata.relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const morphNameFromMorphFieldMetadataName =
getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata: sourceFlatFieldMetadata,
nameSingular: targetFlatObjectMetadata.nameSingular,
namePlural: targetFlatObjectMetadata.namePlural,
});
return fromMorphOrRelationFlatFieldMetadataToRelationDto({
sourceFlatFieldMetadata: {
...sourceFlatFieldMetadata,
name: morphNameFromMorphFieldMetadataName,
},
targetFlatFieldMetadata,
targetFlatObjectMetadata,
sourceFlatObjectMetadata,
});
}),
);
}
return relationDtos;
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
});
},
);
}
@@ -0,0 +1,73 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { findAllOthersMorphRelationFlatFieldMetadatasOrThrow } from 'src/engine/metadata-modules/flat-field-metadata/utils/find-all-others-morph-relation-flat-field-metadatas-or-throw.util';
import { fromMorphOrRelationFlatFieldMetadataToRelationDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-morph-or-relation-flat-field-metadata-to-relation-dto.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util';
type ResolveMorphRelationsFromFlatFieldMetadataArgs = {
morphFlatFieldMetadata: FlatFieldMetadata<FieldMetadataType.MORPH_RELATION>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
};
export const resolveMorphRelationsFromFlatFieldMetadata = ({
morphFlatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
}: ResolveMorphRelationsFromFlatFieldMetadataArgs): RelationDTO[] => {
const sourceFlatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: morphFlatFieldMetadata.objectMetadataId,
});
const relatedMorphFlatFieldMetadatas =
findAllOthersMorphRelationFlatFieldMetadatasOrThrow({
flatFieldMetadata: morphFlatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadata: sourceFlatObjectMetadata,
});
const allMorphFlatFieldMetadatas = [
morphFlatFieldMetadata,
...relatedMorphFlatFieldMetadatas,
];
return allMorphFlatFieldMetadatas.flatMap((sourceFlatFieldMetadata) => {
const targetFlatFieldMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: sourceFlatFieldMetadata.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isMorphOrRelationFlatFieldMetadata(targetFlatFieldMetadata)) {
return [];
}
const targetFlatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: sourceFlatFieldMetadata.relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const morphNameFromMorphFieldMetadataName =
getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata: sourceFlatFieldMetadata,
nameSingular: targetFlatObjectMetadata.nameSingular,
namePlural: targetFlatObjectMetadata.namePlural,
});
return fromMorphOrRelationFlatFieldMetadataToRelationDto({
sourceFlatFieldMetadata: {
...sourceFlatFieldMetadata,
name: morphNameFromMorphFieldMetadataName,
},
targetFlatFieldMetadata,
targetFlatObjectMetadata,
sourceFlatObjectMetadata,
});
});
};
@@ -0,0 +1,88 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { pickMorphGroupSurvivor } from 'src/engine/dataloaders/utils/pick-morph-group-survivor.util';
import { RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { findAllOthersMorphRelationFlatFieldMetadatasOrThrow } from 'src/engine/metadata-modules/flat-field-metadata/utils/find-all-others-morph-relation-flat-field-metadatas-or-throw.util';
import { fromMorphOrRelationFlatFieldMetadataToRelationDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-morph-or-relation-flat-field-metadata-to-relation-dto.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util';
type ResolveRelationFromFlatFieldMetadataArgs = {
sourceFlatFieldMetadata: FlatFieldMetadata<FieldMetadataType.RELATION>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
};
export const resolveRelationFromFlatFieldMetadata = ({
sourceFlatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
}: ResolveRelationFromFlatFieldMetadataArgs): RelationDTO | null => {
const sourceFlatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: sourceFlatFieldMetadata.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const targetFlatFieldMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: sourceFlatFieldMetadata.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isMorphOrRelationFlatFieldMetadata(targetFlatFieldMetadata)) {
return null;
}
const targetFlatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: targetFlatFieldMetadata.objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (
isFlatFieldMetadataOfType(
targetFlatFieldMetadata,
FieldMetadataType.MORPH_RELATION,
)
) {
const morphNameFromMorphFieldMetadataName =
getMorphNameFromMorphFieldMetadataName({
morphRelationFlatFieldMetadata: targetFlatFieldMetadata,
nameSingular: sourceFlatObjectMetadata.nameSingular,
namePlural: sourceFlatObjectMetadata.namePlural,
});
const allMorphFlatFieldMetadatas = [
targetFlatFieldMetadata,
...findAllOthersMorphRelationFlatFieldMetadatasOrThrow({
flatFieldMetadata: targetFlatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadata: targetFlatObjectMetadata,
}),
];
const survivorMorphField = pickMorphGroupSurvivor(
allMorphFlatFieldMetadatas,
);
return fromMorphOrRelationFlatFieldMetadataToRelationDto({
sourceFlatFieldMetadata,
sourceFlatObjectMetadata,
targetFlatFieldMetadata: {
...survivorMorphField,
name: morphNameFromMorphFieldMetadataName,
},
targetFlatObjectMetadata,
});
}
return fromMorphOrRelationFlatFieldMetadataToRelationDto({
sourceFlatFieldMetadata,
targetFlatFieldMetadata,
targetFlatObjectMetadata,
sourceFlatObjectMetadata,
});
};
@@ -12,15 +12,6 @@ export class ObjectRecordEventWithQueryIdsDTO {
objectRecordEvent: ObjectRecordEventDTO;
}
@ObjectType('MetadataEventWithQueryIds')
export class MetadataEventWithQueryIdsDTO {
@Field(() => [String])
queryIds: string[];
@Field(() => MetadataEventDTO)
metadataEvent: MetadataEventDTO;
}
@ObjectType('EventSubscription')
export class EventSubscriptionDTO {
@Field(() => String)
@@ -29,6 +20,6 @@ export class EventSubscriptionDTO {
@Field(() => [ObjectRecordEventWithQueryIdsDTO])
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIdsDTO[];
@Field(() => [MetadataEventWithQueryIdsDTO])
metadataEventsWithQueryIds: MetadataEventWithQueryIdsDTO[];
@Field(() => [MetadataEventDTO])
metadataEvents: MetadataEventDTO[];
}
@@ -6,8 +6,5 @@ export type EventStreamPayload = {
queryIds: string[];
objectRecordEvent: ObjectRecordSubscriptionEvent;
}[];
metadataEventsWithQueryIds: {
queryIds: string[];
metadataEvent: MetadataEvent;
}[];
metadataEvents: MetadataEvent[];
};
@@ -0,0 +1,64 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { resolveMorphRelationsFromFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/resolve-morph-relations-from-flat-field-metadata.util';
import { resolveRelationFromFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/resolve-relation-from-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
type EnrichFieldMetadataEventArgs = {
record: Record<string, unknown>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
};
export const enrichFieldMetadataEventWithRelations = ({
record,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
}: EnrichFieldMetadataEventArgs): Record<string, unknown> => {
const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: record.id as string,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(flatFieldMetadata)) {
return record;
}
try {
if (
isFlatFieldMetadataOfType(flatFieldMetadata, FieldMetadataType.RELATION)
) {
const relation = resolveRelationFromFlatFieldMetadata({
sourceFlatFieldMetadata: flatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return isDefined(relation) ? { ...record, relation } : record;
}
if (
isFlatFieldMetadataOfType(
flatFieldMetadata,
FieldMetadataType.MORPH_RELATION,
)
) {
const morphRelations = resolveMorphRelationsFromFlatFieldMetadata({
morphFlatFieldMetadata: flatFieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return { ...record, morphRelations };
}
} catch {
return record;
}
return record;
};
@@ -90,7 +90,7 @@ export class WorkspaceEventEmitterResolver {
return {
eventStreamId: variables.eventStreamId,
objectRecordEventsWithQueryIds: payload.objectRecordEventsWithQueryIds,
metadataEventsWithQueryIds: payload.metadataEventsWithQueryIds,
metadataEvents: payload.metadataEvents,
};
},
})
@@ -143,7 +143,7 @@ export class WorkspaceEventEmitterResolver {
return wrapAsyncIteratorWithLifecycle(iterator, {
initialValue: {
objectRecordEventsWithQueryIds: [],
metadataEventsWithQueryIds: [],
metadataEvents: [],
},
onHeartbeat: () =>
this.eventStreamService.refreshEventStreamTTL({
@@ -31,6 +31,7 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/workspace-event-emitter/utils/enrich-field-metadata-event-with-relations.util';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map';
import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type';
import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type';
@@ -130,23 +131,29 @@ export class WorkspaceEventEmitterService {
? await this.fetchObjectRecordStreamContext(workspaceId)
: undefined;
const enrichedMetadataEventBatch = isMetadata
? await this.enrichFieldMetadataEventsWithRelations(
eventBatch as MetadataEventBatch,
)
: undefined;
for (const [streamChannelId, streamData] of streamsData) {
if (!isDefined(streamData)) {
streamIdsToRemove.push(streamChannelId);
continue;
}
if (Object.keys(streamData.queries).length === 0) {
continue;
}
if (isMetadata) {
if (isMetadata && isDefined(enrichedMetadataEventBatch)) {
await this.processMetadataStreamEvents(
streamChannelId,
streamData,
eventBatch as MetadataEventBatch,
enrichedMetadataEventBatch,
);
} else {
} else if (!isMetadata) {
if (Object.keys(streamData.queries).length === 0) {
continue;
}
if (!isDefined(objectRecordStreamContext)) {
continue;
}
@@ -186,19 +193,14 @@ export class WorkspaceEventEmitterService {
return;
}
const metadataEventsWithQueryIds = metadataEventBatch.events.map(
(metadataEvent) => ({
queryIds: [] as string[],
metadataEvent: {
...metadataEvent,
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
},
}),
);
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
...metadataEvent,
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
}));
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: [],
metadataEventsWithQueryIds,
metadataEvents,
};
await this.subscriptionService.publishToEventStream({
@@ -208,6 +210,47 @@ export class WorkspaceEventEmitterService {
});
}
private async enrichFieldMetadataEventsWithRelations(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
if (metadataEventBatch.metadataName !== 'fieldMetadata') {
return metadataEventBatch;
}
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: metadataEventBatch.workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const enrichedEvents = metadataEventBatch.events.map((event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const enrichedAfter = enrichFieldMetadataEventWithRelations({
record: event.properties.after as Record<string, unknown>,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private async processObjectRecordStreamEvents(
streamChannelId: string,
streamData: EventStreamData,
@@ -315,7 +358,7 @@ export class WorkspaceEventEmitterService {
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: matchedEvents,
metadataEventsWithQueryIds: [],
metadataEvents: [],
};
await this.subscriptionService.publishToEventStream({