Compare commits

...
Author SHA1 Message Date
ehconitin 321880828b Merge remote-tracking branch 'upstream/main' into fix-kanban-same-column-sse-propagation 2026-06-02 16:17:47 +05:30
ehconitin 629907292a Merge remote-tracking branch 'upstream/main' into fix-kanban-same-column-sse-propagation 2026-06-02 14:45:13 +05:30
ehconitin 2432479968 Merge remote-tracking branch 'upstream/main' into fix-kanban-same-column-sse-propagation
# Conflicts:
#	packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts
2026-06-01 23:13:17 +05:30
ehconitin c15bcd3bec fix(kanban): exclude position changes from timeline activities 2026-06-01 20:28:01 +05:30
ehconitin f91207eabc refactor(kanban): drop redundant position filter in board update-effects hook 2026-06-01 18:21:46 +05:30
ehconitin 5b04676a51 one export per file 2026-06-01 16:14:55 +05:30
ehconitin ef4bcf186a more 2026-06-01 16:08:08 +05:30
ehconitin b13aea14c1 naming 2026-06-01 14:10:52 +05:30
ehconitin 41d825d2e5 Merge remote-tracking branch 'upstream/main' into fix-kanban-same-column-sse-propagation 2026-06-01 14:10:42 +05:30
ehconitin c9da354177 Fix kanban position SSE handling 2026-05-29 21:09:53 +05:30
ehconitin 9ecf28bb3c Merge remote-tracking branch 'upstream/main' into fix-kanban-same-column-sse-propagation 2026-05-29 14:31:44 +05:30
ehconitin dc75165d27 Merge remote-tracking branch 'upstream/main' into fix-kanban-same-column-sse-propagation 2026-05-27 16:01:12 +05:30
ehconitin c86732d902 test(server): update object-record-changed-values spec for POSITION inclusion 2026-05-17 19:10:58 +05:30
ehconitin 7fb6074028 chore(server): apply prettier formatting to workflow database event trigger listener 2026-05-17 18:57:34 +05:30
ehconitin c7fc9bbd09 chore(twenty-front): apply prettier formatting to useTriggerRecordBoardInitialQuery 2026-05-17 18:52:10 +05:30
ehconitin 26eb28b792 chore(server): drop redundant comments on position-only event filters 2026-05-17 18:45:27 +05:30
ehconitin c89d1a061d fix: propagate kanban same-column reorders across browsers via SSE 2026-05-17 18:26:50 +05:30
ehconitin 6d576ebbcd fix(twenty-front): preserve kanban scroll on data-driven board re-init 2026-05-17 17:12:32 +05:30
13 changed files with 686 additions and 143 deletions
@@ -2,8 +2,9 @@ import { useStore } from 'jotai';
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { useGetShouldInitializeRecordBoardForUpdateInputs } from '@/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs';
import { useGetRecordBoardEffectsForUpdateInputs } from '@/object-record/record-board/hooks/useGetRecordBoardEffectsForUpdateInputs';
import { useRemoveRecordsFromBoard } from '@/object-record/record-board/hooks/useRemoveRecordsFromBoard';
import { useRepositionRecordsOnBoard } from '@/object-record/record-board/hooks/useRepositionRecordsOnBoard';
import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery';
import { recordGroupFromGroupValueComponentFamilySelector } from '@/object-record/record-group/states/selectors/recordGroupFromGroupValueComponentFamilySelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
@@ -14,15 +15,16 @@ import { useAtomComponentFamilySelectorCallbackState } from '@/ui/utilities/stat
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
export const RecordBoardDataChangedEffect = () => {
const store = useStore();
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { triggerRecordBoardInitialQuery } =
const { triggerRecordBoardInitialQueryWithoutScrollReset } =
useTriggerRecordBoardInitialQuery();
const { getShouldInitializeRecordBoardForUpdateInputs } =
useGetShouldInitializeRecordBoardForUpdateInputs();
const { getRecordBoardEffectsForUpdateInputs } =
useGetRecordBoardEffectsForUpdateInputs();
const { repositionRecordsOnBoard } = useRepositionRecordsOnBoard();
const recordGroupFromGroupValueCallbackState =
useAtomComponentFamilySelectorCallbackState(
@@ -53,17 +55,35 @@ export const RecordBoardDataChangedEffect = () => {
? [objectRecordOperation.result.updateInput]
: objectRecordOperation.result.updateInputs;
const shouldInitializeForUpdateOperation =
getShouldInitializeRecordBoardForUpdateInputs(updateInputs);
const recordBoardUpdateEffect =
getRecordBoardEffectsForUpdateInputs(updateInputs);
if (shouldInitializeForUpdateOperation) {
triggerRecordBoardInitialQuery();
switch (recordBoardUpdateEffect) {
case 'trigger-initial-query': {
triggerRecordBoardInitialQueryWithoutScrollReset();
break;
}
case 'reposition-records': {
const allRecordsRepositioned =
repositionRecordsOnBoard(updateInputs);
if (!allRecordsRepositioned) {
triggerRecordBoardInitialQueryWithoutScrollReset();
}
break;
}
case 'none': {
break;
}
default: {
assertUnreachable(recordBoardUpdateEffect);
}
}
}
break;
case 'create-one': {
if (objectRecordOperation.createdRecord.position === 'first') {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQueryWithoutScrollReset();
} else {
const createdRecordPosition =
objectRecordOperation.createdRecord.position;
@@ -106,7 +126,7 @@ export const RecordBoardDataChangedEffect = () => {
const groupIsEmpty = recordIdsWithoutCreatedRecord.length === 0;
if (groupIsEmpty) {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQueryWithoutScrollReset();
return;
}
@@ -122,7 +142,7 @@ export const RecordBoardDataChangedEffect = () => {
if (
createdRecordPosition < (firstExistingRecordInGroup.position ?? 0)
) {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQueryWithoutScrollReset();
}
}
break;
@@ -148,14 +168,15 @@ export const RecordBoardDataChangedEffect = () => {
return;
}
default: {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQueryWithoutScrollReset();
}
}
},
[
store,
triggerRecordBoardInitialQuery,
getShouldInitializeRecordBoardForUpdateInputs,
triggerRecordBoardInitialQueryWithoutScrollReset,
getRecordBoardEffectsForUpdateInputs,
repositionRecordsOnBoard,
recordIndexGroupFieldMetadataItem,
recordGroupFromGroupValueCallbackState,
recordIndexRecordIdsByGroupCallbackState,
@@ -1,4 +1,5 @@
import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
import { type RecordBoardUpdateEffect } from '@/object-record/record-board/types/RecordBoardUpdateEffect';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
@@ -8,7 +9,7 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined, mapById } from 'twenty-shared/utils';
export const useGetShouldInitializeRecordBoardForUpdateInputs = () => {
export const useGetRecordBoardEffectsForUpdateInputs = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
@@ -27,9 +28,9 @@ export const useGetShouldInitializeRecordBoardForUpdateInputs = () => {
recordIndexGroupFieldMetadataItemComponentState,
);
const getShouldInitializeRecordBoardForUpdateInputs = (
const getRecordBoardEffectsForUpdateInputs = (
updateInputs: ObjectRecordOperationUpdateInput[],
) => {
): RecordBoardUpdateEffect => {
const updatedFieldNames = new Set<string>();
let thereIsAnUpdateOnAFilteredField = false;
let thereIsAnUpdateOnASortedField = false;
@@ -83,18 +84,18 @@ export const useGetShouldInitializeRecordBoardForUpdateInputs = () => {
}
}
if (updatedFieldNames.has('position')) {
return false;
if (thereIsAnUpdateOnAFilteredField || thereIsAnUpdateOnASortedField) {
return 'trigger-initial-query';
}
return (
thereIsAnUpdateOnAFilteredField ||
thereIsAnUpdateOnASortedField ||
thereIsAnUpdateOnAGroupField
);
if (updatedFieldNames.has('position') || thereIsAnUpdateOnAGroupField) {
return 'reposition-records';
}
return 'none';
};
return {
getShouldInitializeRecordBoardForUpdateInputs,
getRecordBoardEffectsForUpdateInputs,
};
};
@@ -0,0 +1,154 @@
import { useStore } from 'jotai';
import { extractRecordPositions } from '@/object-record/record-drag/utils/extractRecordPositions';
import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type ObjectRecordOperationUpdateInput } from '@/object-record/types/ObjectRecordOperationUpdateInput';
import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState';
import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { sortByProperty } from '~/utils/array/sortByProperty';
export const useRepositionRecordsOnBoard = () => {
const store = useStore();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const recordGroupDefinitionsCallbackState =
useAtomComponentSelectorCallbackState(
recordGroupDefinitionsComponentSelector,
);
const recordIndexGroupFieldMetadataItem = useAtomComponentStateCallbackState(
recordIndexGroupFieldMetadataItemComponentState,
);
const recordIndexRecordIdsByGroupCallbackState =
useAtomComponentFamilyStateCallbackState(
recordIndexRecordIdsByGroupComponentFamilyState,
);
const repositionRecordsOnBoard = useCallback(
(updateInputs: ObjectRecordOperationUpdateInput[]): boolean => {
const groupFieldMetadataItem = store.get(
recordIndexGroupFieldMetadataItem,
);
if (!isDefined(groupFieldMetadataItem)) {
return false;
}
const groupFieldName = groupFieldMetadataItem.name;
const recordGroupDefinitions = store.get(
recordGroupDefinitionsCallbackState,
);
// Column membership changes are accumulated here and committed only once
// the whole batch succeeds: a mid-batch bail-out leaves the columns
// untouched and the caller re-runs the board query.
const updatedRecordIdsByGroupId: Record<string, string[]> = {};
const getRecordIdsForGroup = (recordGroupId: string): string[] =>
updatedRecordIdsByGroupId[recordGroupId] ??
store.get(recordIndexRecordIdsByGroupCallbackState(recordGroupId));
for (const updateInput of updateInputs) {
const recordId = updateInput.recordId;
const currentRecord = store.get(
recordStoreFamilyState.atomFamily(recordId),
);
if (!isDefined(currentRecord)) {
return false;
}
const updatedFields: Record<string, unknown> = {};
for (const updatedField of updateInput.updatedFields) {
Object.assign(updatedFields, updatedField ?? {});
}
const sourceRecordGroup = recordGroupDefinitions.find(
(recordGroupDefinition) =>
getRecordIdsForGroup(recordGroupDefinition.id).includes(recordId),
);
if (!isDefined(sourceRecordGroup)) {
return false;
}
const targetRecordGroup =
groupFieldName in updatedFields
? recordGroupDefinitions.find(
(recordGroupDefinition) =>
recordGroupDefinition.value === updatedFields[groupFieldName],
)
: sourceRecordGroup;
if (!isDefined(targetRecordGroup)) {
return false;
}
// Upsert in place so extractRecordPositions reads the new position when
// several records in the same batch land in the same column.
upsertRecordsInStore({
partialRecords: [
{
...currentRecord,
...updatedFields,
id: recordId,
},
],
});
if (sourceRecordGroup.id !== targetRecordGroup.id) {
updatedRecordIdsByGroupId[sourceRecordGroup.id] =
getRecordIdsForGroup(sourceRecordGroup.id).filter(
(id) => id !== recordId,
);
}
const targetRecordIdsWithoutRecord = getRecordIdsForGroup(
targetRecordGroup.id,
).filter((id) => id !== recordId);
const targetRecordsWithPositions = extractRecordPositions(
[...targetRecordIdsWithoutRecord, recordId],
store,
);
targetRecordsWithPositions.sort(sortByProperty('position', 'asc'));
updatedRecordIdsByGroupId[targetRecordGroup.id] =
targetRecordsWithPositions.map((record) => record.id);
}
for (const [recordGroupId, recordIds] of Object.entries(
updatedRecordIdsByGroupId,
)) {
store.set(
recordIndexRecordIdsByGroupCallbackState(recordGroupId),
recordIds,
);
}
return true;
},
[
store,
upsertRecordsInStore,
recordGroupDefinitionsCallbackState,
recordIndexGroupFieldMetadataItem,
recordIndexRecordIdsByGroupCallbackState,
],
);
return {
repositionRecordsOnBoard,
};
};
@@ -75,111 +75,126 @@ export const useTriggerRecordBoardInitialQuery = () => {
objectMetadataItem,
});
const runRecordBoardInitialQuery = useCallback(
async ({ shouldResetScroll }: { shouldResetScroll: boolean }) => {
store.set(recordIndexRecordGroupsAreInInitialLoading, true);
const cleanStateBeforeExit = () => {
store.set(recordIndexRecordGroupsAreInInitialLoading, false);
setLastRecordBoardQueryIdentifier(queryIdentifier);
setRecordBoardCurrentGroupByQueryOffset(0);
if (shouldResetScroll) {
scrollWrapperHTMLElement?.scrollTo({ top: 0, left: 0 });
}
};
const recordIndexGroupsRecordsGroupByLazyQueryResult =
await executeRecordIndexGroupsRecordsLazyGroupBy();
if (!isDefined(recordIndexGroupsRecordsGroupByLazyQueryResult)) {
cleanStateBeforeExit();
return;
}
const queryFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const groups =
recordIndexGroupsRecordsGroupByLazyQueryResult.data?.[queryFieldName];
if (!isDefined(groups)) {
cleanStateBeforeExit();
return;
}
for (const recordGroupDefinition of recordGroupDefinitions) {
const foundGroupInResult = groups?.find(
(recordGroup: any) =>
(recordGroup.groupByDimensionValues[0] as string) ===
recordGroupDefinition.value,
);
if (!isDefined(foundGroupInResult)) {
setRecordIdsForColumn(recordGroupDefinition.id, []);
store.set(
recordBoardShouldFetchMoreInColumnFamilyCallbackState(
recordGroupDefinition.id,
),
false,
);
continue;
}
const records = getRecordsFromRecordConnection({
recordConnection: foundGroupInResult,
});
if (!isNonEmptyArray(records)) {
setRecordIdsForColumn(recordGroupDefinition.id, []);
store.set(
recordBoardShouldFetchMoreInColumnFamilyCallbackState(
recordGroupDefinition.id,
),
false,
);
continue;
}
upsertRecordsInStore({ partialRecords: records });
setRecordIdsForColumn(recordGroupDefinition.id, records);
if (records.length < RECORD_BOARD_QUERY_PAGE_SIZE) {
store.set(
recordBoardShouldFetchMoreInColumnFamilyCallbackState(
recordGroupDefinition.id,
),
false,
);
} else {
store.set(
recordBoardShouldFetchMoreInColumnFamilyCallbackState(
recordGroupDefinition.id,
),
true,
);
}
}
cleanStateBeforeExit();
},
[
recordIndexRecordGroupsAreInInitialLoading,
store,
executeRecordIndexGroupsRecordsLazyGroupBy,
objectMetadataItem,
setLastRecordBoardQueryIdentifier,
queryIdentifier,
setRecordBoardCurrentGroupByQueryOffset,
scrollWrapperHTMLElement,
recordGroupDefinitions,
upsertRecordsInStore,
setRecordIdsForColumn,
recordBoardShouldFetchMoreInColumnFamilyCallbackState,
],
);
const triggerRecordBoardInitialQuery = useCallback(async () => {
store.set(recordIndexRecordGroupsAreInInitialLoading, true);
await runRecordBoardInitialQuery({ shouldResetScroll: true });
}, [runRecordBoardInitialQuery]);
const cleanStateBeforeExit = () => {
store.set(recordIndexRecordGroupsAreInInitialLoading, false);
setLastRecordBoardQueryIdentifier(queryIdentifier);
setRecordBoardCurrentGroupByQueryOffset(0);
scrollWrapperHTMLElement?.scrollTo({ top: 0, left: 0 });
};
const recordIndexGroupsRecordsGroupByLazyQueryResult =
await executeRecordIndexGroupsRecordsLazyGroupBy();
if (!isDefined(recordIndexGroupsRecordsGroupByLazyQueryResult)) {
cleanStateBeforeExit();
return;
}
const queryFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const groups =
recordIndexGroupsRecordsGroupByLazyQueryResult.data?.[queryFieldName];
if (!isDefined(groups)) {
cleanStateBeforeExit();
return;
}
for (const recordGroupDefinition of recordGroupDefinitions) {
const foundGroupInResult = groups?.find(
(recordGroup: any) =>
(recordGroup.groupByDimensionValues[0] as string) ===
recordGroupDefinition.value,
);
if (!isDefined(foundGroupInResult)) {
setRecordIdsForColumn(recordGroupDefinition.id, []);
store.set(
recordBoardShouldFetchMoreInColumnFamilyCallbackState(
recordGroupDefinition.id,
),
false,
);
continue;
}
const records = getRecordsFromRecordConnection({
recordConnection: foundGroupInResult,
});
if (!isNonEmptyArray(records)) {
setRecordIdsForColumn(recordGroupDefinition.id, []);
store.set(
recordBoardShouldFetchMoreInColumnFamilyCallbackState(
recordGroupDefinition.id,
),
false,
);
continue;
}
upsertRecordsInStore({ partialRecords: records });
setRecordIdsForColumn(recordGroupDefinition.id, records);
if (records.length < RECORD_BOARD_QUERY_PAGE_SIZE) {
store.set(
recordBoardShouldFetchMoreInColumnFamilyCallbackState(
recordGroupDefinition.id,
),
false,
);
} else {
store.set(
recordBoardShouldFetchMoreInColumnFamilyCallbackState(
recordGroupDefinition.id,
),
true,
);
}
}
cleanStateBeforeExit();
}, [
recordIndexRecordGroupsAreInInitialLoading,
store,
executeRecordIndexGroupsRecordsLazyGroupBy,
objectMetadataItem,
setLastRecordBoardQueryIdentifier,
queryIdentifier,
setRecordBoardCurrentGroupByQueryOffset,
scrollWrapperHTMLElement,
recordGroupDefinitions,
upsertRecordsInStore,
setRecordIdsForColumn,
recordBoardShouldFetchMoreInColumnFamilyCallbackState,
]);
const triggerRecordBoardInitialQueryWithoutScrollReset =
useCallback(async () => {
await runRecordBoardInitialQuery({ shouldResetScroll: false });
}, [runRecordBoardInitialQuery]);
return {
triggerRecordBoardInitialQuery,
triggerRecordBoardInitialQueryWithoutScrollReset,
};
};
@@ -0,0 +1,4 @@
export type RecordBoardUpdateEffect =
| 'trigger-initial-query'
| 'reposition-records'
| 'none';
@@ -151,7 +151,7 @@ describe('objectRecordChangedValues', () => {
expect(result).toEqual(expectedChanges);
});
it('ignores changes to POSITION fields', () => {
it('detects changes to POSITION fields', () => {
const positionFieldId = 'position-field-id';
const positionUniversalId = 'position-universal-id';
@@ -196,7 +196,55 @@ describe('objectRecordChangedValues', () => {
expect(result).toEqual({
name: { before: 'Original', after: 'Updated' },
position: { before: 1, after: 5 },
});
});
it('returns a non-empty diff for a position-only change', () => {
const positionFieldId = 'position-field-id';
const positionUniversalId = 'position-universal-id';
const objectMetadataWithPosition: FlatObjectMetadata = {
...mockObjectMetadata,
fieldIds: [positionFieldId],
};
const flatFieldMetadataMapsWithPosition: FlatEntityMaps<FlatFieldMetadata> =
{
byUniversalIdentifier: {
[positionUniversalId]: {
id: positionFieldId,
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: positionUniversalId,
} as FlatFieldMetadata,
},
universalIdentifierById: {
[positionFieldId]: positionUniversalId,
},
universalIdentifiersByApplicationId: {},
};
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516n',
position: 1,
name: 'Unchanged',
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516n',
position: 5,
name: 'Unchanged',
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
objectMetadataWithPosition,
flatFieldMetadataMapsWithPosition,
);
expect(result).toEqual({
position: { before: 1, after: 5 },
});
expect(result).not.toHaveProperty('position');
});
});
@@ -79,7 +79,6 @@ export const objectRecordChangedValues = (
if (
key === 'updatedAt' ||
key === 'searchVector' ||
field?.type === FieldMetadataType.POSITION ||
(isDefined(field) && isManyToOneRelationField(field)) ||
field?.type === FieldMetadataType.RELATION ||
field?.type === FieldMetadataType.MORPH_RELATION
@@ -333,6 +333,97 @@ describe('transformEventBatchToEventPayloads', () => {
});
});
describe('position-only updates', () => {
it('should include position-only events when the trigger has no updatedFields filter', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['position'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['name'] },
}),
],
});
const logicFunctions = [
createMockLogicFunction({
databaseEventTriggerSettings: { eventName: 'company.updated' },
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
logicFunctions,
});
expect(result).toHaveLength(2);
expect(
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
).toEqual(['record-1', 'record-2']);
});
it('should include events that change other fields alongside position', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['position', 'name'] },
}),
],
});
const logicFunctions = [
createMockLogicFunction({
databaseEventTriggerSettings: { eventName: 'company.updated' },
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
logicFunctions,
});
expect(result).toHaveLength(1);
});
it('should exclude position-only events when the trigger filters on another field', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch({
name: 'company.updated',
events: [
createMockEvent({
recordId: 'record-1',
properties: { after: {}, updatedFields: ['position'] },
}),
createMockEvent({
recordId: 'record-2',
properties: { after: {}, updatedFields: ['name'] },
}),
],
});
const logicFunctions = [
createMockLogicFunction({
databaseEventTriggerSettings: {
eventName: 'company.updated',
updatedFields: ['name'],
},
}),
];
const result = transformEventBatchToEventPayloads({
workspaceEventBatch,
logicFunctions,
});
expect(result).toHaveLength(1);
expect(
result.map((r) => (r.payload as ObjectRecordEvent).recordId),
).toEqual(['record-2']);
});
});
describe('edge cases', () => {
it('should return empty array when no logic functions provided', () => {
const workspaceEventBatch = createMockWorkspaceEventBatch();
@@ -53,11 +53,11 @@ const filterEventsByUpdatedFields = ({
operation: string;
triggerUpdatedFields?: string[];
}): ObjectRecordEvent[] => {
if (
operation !== 'updated' ||
!isDefined(triggerUpdatedFields) ||
triggerUpdatedFields.length === 0
) {
if (operation !== 'updated') {
return events;
}
if (!isDefined(triggerUpdatedFields) || triggerUpdatedFields.length === 0) {
return events;
}
@@ -246,4 +246,53 @@ describe('transformEventBatchToWebhookEvents', () => {
expect(resultWithoutEventDate).toEqual(expectedResultWithoutEventDate);
});
it('should include position-only update events', () => {
const workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent> = {
workspaceId: 'workspaceId',
objectMetadata: mockObjectMetadata,
name: 'objectNameSingular.updated',
events: [
{
recordId: 'recordId-1',
properties: {
after: { id: 'id-1', nameSingular: 'nameSingular-1' },
updatedFields: ['position'],
},
},
{
recordId: 'recordId-2',
properties: {
after: { id: 'id-2', nameSingular: 'nameSingular-2' },
updatedFields: ['nameSingular', 'position'],
},
},
],
};
const webhooks = [
{
id: 'webhook-id',
targetUrl: 'targetUrl',
secret: 'secret',
},
] as WebhookEntity[];
const result = transformEventBatchToWebhookEvents({
workspaceEventBatch,
webhooks,
});
expect(result).toHaveLength(2);
expect(result[0].record).toEqual({
id: 'id-1',
nameSingular: 'nameSingular-1',
});
expect(result[0].updatedFields).toEqual(['position']);
expect(result[1].record).toEqual({
id: 'id-2',
nameSingular: 'nameSingular-2',
});
expect(result[1].updatedFields).toEqual(['nameSingular', 'position']);
});
});
@@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import { type ObjectRecordBaseEvent } from 'twenty-shared/database-events';
import { type ObjectRecord } from 'twenty-shared/types';
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -49,9 +50,16 @@ export class TimelineActivityService {
const { objectSingularName } = parseEventNameOrThrow(name);
const eventsWithoutPositionDiff =
await this.excludePositionFieldsFromEventsDiff({
events,
objectMetadata,
workspaceId,
});
const timelineActivitiesPayloads =
await this.transformEventsToTimelineActivityPayloads({
events,
events: eventsWithoutPositionDiff,
objectMetadata,
workspaceId,
name,
@@ -88,6 +96,63 @@ export class TimelineActivityService {
}
}
// Position changes reach other consumers (SSE, webhooks, workflows) but render
// blank in the timeline, so exclude them to avoid empty activity rows.
private async excludePositionFieldsFromEventsDiff({
events,
objectMetadata,
workspaceId,
}: {
events: ObjectRecordBaseEvent[];
objectMetadata: FlatObjectMetadata;
workspaceId: string;
}): Promise<ObjectRecordBaseEvent[]> {
const someEventHasDiff = events.some((event) =>
isDefined(event.properties.diff),
);
if (!someEventHasDiff) {
return events;
}
const { flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const positionFieldNames = new Set(
getFlatFieldsFromFlatObjectMetadata(objectMetadata, flatFieldMetadataMaps)
.filter((field) => field.type === FieldMetadataType.POSITION)
.map((field) => field.name),
);
if (positionFieldNames.size === 0) {
return events;
}
return events.map((event) => {
const diff = event.properties.diff;
if (!isDefined(diff)) {
return event;
}
const diffWithoutPositionFields = Object.fromEntries(
Object.entries(diff).filter(
([fieldName]) => !positionFieldNames.has(fieldName),
),
);
return {
...event,
properties: { ...event.properties, diff: diffWithoutPositionFields },
};
});
}
private async transformEventsToTimelineActivityPayloads({
events,
workspaceId,
@@ -352,5 +352,103 @@ describe('WorkflowDatabaseEventTriggerListener', () => {
{ retryLimit: 3 },
);
});
it('should trigger workflow for position-only updates when no fields are specified', async () => {
const positionOnlyPayload: WorkspaceEventBatch<any> = {
...mockPayload,
events: [
{
...mockPayload.events[0],
properties: {
updatedFields: ['position'],
before: { position: 1 },
after: { position: 2 },
},
},
],
};
mockRepository.find.mockResolvedValue([
{
...mockEventListeners[0],
settings: {
eventName: databaseEventName,
fields: undefined,
},
},
]);
await listener.handleObjectRecordUpdateEvent(positionOnlyPayload);
expect(messageQueueService.add).toHaveBeenCalledWith(
WorkflowTriggerJob.name,
{
workspaceId,
workflowId,
payload: positionOnlyPayload.events[0],
},
{ retryLimit: 3 },
);
});
it('should trigger workflow when position changes alongside another field', async () => {
const positionAndFieldPayload: WorkspaceEventBatch<any> = {
...mockPayload,
events: [
{
...mockPayload.events[0],
properties: {
updatedFields: ['field1', 'position'],
before: { field1: 'old', position: 1 },
after: { field1: 'new', position: 2 },
},
},
],
};
mockRepository.find.mockResolvedValue([
{
...mockEventListeners[0],
settings: {
eventName: databaseEventName,
fields: undefined,
},
},
]);
await listener.handleObjectRecordUpdateEvent(positionAndFieldPayload);
expect(messageQueueService.add).toHaveBeenCalled();
});
it('should not trigger workflow for position-only updates when fields are specified', async () => {
const positionOnlyPayload: WorkspaceEventBatch<any> = {
...mockPayload,
events: [
{
...mockPayload.events[0],
properties: {
updatedFields: ['position'],
before: { position: 1 },
after: { position: 2 },
},
},
],
};
mockRepository.find.mockResolvedValue([
{
...mockEventListeners[0],
settings: {
eventName: databaseEventName,
fields: ['field1'],
},
},
]);
await listener.handleObjectRecordUpdateEvent(positionOnlyPayload);
expect(messageQueueService.add).not.toHaveBeenCalled();
});
});
});
@@ -394,26 +394,24 @@ export class WorkflowDatabaseEventTriggerListener {
if (action === DatabaseEventAction.UPDATED) {
const settings = eventListener.settings as UpdateEventTriggerSettings;
const updateEventPayload = eventPayload as ObjectRecordUpdateEvent;
const updatedFields = updateEventPayload?.properties?.updatedFields ?? [];
return (
!settings.fields ||
settings.fields.length === 0 ||
settings.fields.some((field) =>
updateEventPayload?.properties?.updatedFields?.includes(field),
)
settings.fields.some((field) => updatedFields.includes(field))
);
}
if (action === DatabaseEventAction.UPSERTED) {
const settings = eventListener.settings as UpsertEventTriggerSettings;
const upsertEventPayload = eventPayload as ObjectRecordUpsertEvent;
const updatedFields = upsertEventPayload?.properties?.updatedFields ?? [];
return (
!settings.fields ||
settings.fields.length === 0 ||
settings.fields.some((field) =>
upsertEventPayload?.properties?.updatedFields?.includes(field),
)
settings.fields.some((field) => updatedFields.includes(field))
);
}