fix(kanban): preserve scroll on board re-init + propagate same-column reorders via SSE (#20637)

closes
https://discord.com/channels/1130383047699738754/1504130730840821860


https://github.com/user-attachments/assets/d5833031-01c6-4e46-b699-c29c42435a53





## Summary

Fixes two related issues with the kanban (board view) collaboration
experience:

1. **Scroll-to-top on every data change** —
`triggerRecordBoardInitialQuery` always scrolled the board to the top,
even when re-initializing for a single-record data change (SSE echo of
your own mutation, a collaborator's update). Scroll reset only makes
sense when the dataset itself changes (filter / sort / group).
2. **Same-column reorders by other users did not propagate** — the
server's diff function stripped `FieldMetadataType.POSITION`, so
position-only updates produced empty `updatedFields` and short-circuited
event emission entirely. SSE clients never received them.

## What's in here

- **Frontend** — `useTriggerRecordBoardInitialQuery` now exposes a
`triggerRecordBoardInitialQueryWithoutScrollReset` variant; data-driven
re-inits in `RecordBoardDataChangedEffect` use it, while genuine filter
/ sort / group changes keep the scroll-resetting
`triggerRecordBoardInitialQuery`. `getRecordBoardEffectsForUpdateInputs`
classifies each update as `trigger-initial-query` / `reposition-records`
/ `none`. For position- or group-only changes we skip the re-query and
reposition records in place in the store
(`useRepositionRecordsOnBoard`), which avoids the flicker and preserves
scroll.
- **Server** — removes `POSITION` from `objectRecordChangedValues`'
strip list, so position-only updates emit a non-empty diff and flow
through SSE. Position is now treated as a field like any other across
all event consumers (SSE, webhooks, workflows, logic functions); a
trigger with an explicit field filter still excludes it.
This commit is contained in:
nitin
2026-06-11 12:56:51 +05:30
committed by GitHub
parent 9100fb1e9f
commit 20c83e1f86
14 changed files with 670 additions and 137 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 } =
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': {
triggerRecordBoardInitialQuery({ shouldResetScroll: false });
break;
}
case 'reposition-records': {
const allRecordsRepositioned =
repositionRecordsOnBoard(updateInputs);
if (!allRecordsRepositioned) {
triggerRecordBoardInitialQuery({ shouldResetScroll: false });
}
break;
}
case 'none': {
break;
}
default: {
assertUnreachable(recordBoardUpdateEffect);
}
}
}
break;
case 'create-one': {
if (objectRecordOperation.createdRecord.position === 'first') {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQuery({ shouldResetScroll: false });
} else {
const createdRecordPosition =
objectRecordOperation.createdRecord.position;
@@ -106,7 +126,7 @@ export const RecordBoardDataChangedEffect = () => {
const groupIsEmpty = recordIdsWithoutCreatedRecord.length === 0;
if (groupIsEmpty) {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQuery({ shouldResetScroll: false });
return;
}
@@ -122,7 +142,7 @@ export const RecordBoardDataChangedEffect = () => {
if (
createdRecordPosition < (firstExistingRecordInGroup.position ?? 0)
) {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQuery({ shouldResetScroll: false });
}
}
break;
@@ -148,14 +168,15 @@ export const RecordBoardDataChangedEffect = () => {
return;
}
default: {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQuery({ shouldResetScroll: false });
}
}
},
[
store,
triggerRecordBoardInitialQuery,
getShouldInitializeRecordBoardForUpdateInputs,
getRecordBoardEffectsForUpdateInputs,
repositionRecordsOnBoard,
recordIndexGroupFieldMetadataItem,
recordGroupFromGroupValueCallbackState,
recordIndexRecordIdsByGroupCallbackState,
@@ -81,7 +81,7 @@ export const RecordBoardQueryEffect = () => {
!recordIndexRecordGroupsAreInInitialLoading &&
(queryIdentifierHasChanged || recordGroupIdsHaveChanged)
) {
triggerRecordBoardInitialQuery();
triggerRecordBoardInitialQuery({ shouldResetScroll: true });
setLastRecordGroupIds(recordGroupIds);
} else if (
!recordIndexRecordGroupsAreInInitialLoading &&
@@ -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,109 +75,114 @@ export const useTriggerRecordBoardInitialQuery = () => {
objectMetadataItem,
});
const triggerRecordBoardInitialQuery = useCallback(async () => {
store.set(recordIndexRecordGroupsAreInInitialLoading, true);
const triggerRecordBoardInitialQuery = useCallback(
async ({ shouldResetScroll }: { shouldResetScroll: boolean }) => {
store.set(recordIndexRecordGroupsAreInInitialLoading, true);
const cleanStateBeforeExit = () => {
store.set(recordIndexRecordGroupsAreInInitialLoading, false);
const cleanStateBeforeExit = () => {
store.set(recordIndexRecordGroupsAreInInitialLoading, false);
setLastRecordBoardQueryIdentifier(queryIdentifier);
setLastRecordBoardQueryIdentifier(queryIdentifier);
setRecordBoardCurrentGroupByQueryOffset(0);
setRecordBoardCurrentGroupByQueryOffset(0);
scrollWrapperHTMLElement?.scrollTo({ top: 0, left: 0 });
};
if (shouldResetScroll) {
scrollWrapperHTMLElement?.scrollTo({ top: 0, left: 0 });
}
};
const recordIndexGroupsRecordsGroupByLazyQueryResult =
await executeRecordIndexGroupsRecordsLazyGroupBy();
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,
);
}
}
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,
]);
},
[
recordIndexRecordGroupsAreInInitialLoading,
store,
executeRecordIndexGroupsRecordsLazyGroupBy,
objectMetadataItem,
setLastRecordBoardQueryIdentifier,
queryIdentifier,
setRecordBoardCurrentGroupByQueryOffset,
scrollWrapperHTMLElement,
recordGroupDefinitions,
upsertRecordsInStore,
setRecordIdsForColumn,
recordBoardShouldFetchMoreInColumnFamilyCallbackState,
],
);
return {
triggerRecordBoardInitialQuery,
@@ -0,0 +1,4 @@
export type RecordBoardUpdateEffect =
| 'trigger-initial-query'
| 'reposition-records'
| 'none';
@@ -153,7 +153,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';
@@ -198,8 +198,56 @@ 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');
});
describe('with a MANY_TO_ONE relation field', () => {
@@ -112,7 +112,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;
}
@@ -245,4 +245,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,
@@ -351,5 +351,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))
);
}