From 20c83e1f8667ab1e0859e13843fcb400b3962d93 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:56:51 +0530 Subject: [PATCH] fix(kanban): preserve scroll on board re-init + propagate same-column reorders via SSE (#20637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../RecordBoardDataChangedEffect.tsx | 47 +++-- .../components/RecordBoardQueryEffect.tsx | 2 +- ...seGetRecordBoardEffectsForUpdateInputs.ts} | 23 +- .../hooks/useRepositionRecordsOnBoard.ts | 154 ++++++++++++++ .../useTriggerRecordBoardInitialQuery.ts | 197 +++++++++--------- .../types/RecordBoardUpdateEffect.ts | 4 + .../object-record-changed-values.spec.ts | 52 ++++- .../utils/object-record-changed-values.ts | 1 - ...form-event-batch-to-event-payloads.spec.ts | 91 ++++++++ ...transform-event-batch-to-event-payloads.ts | 10 +- ...form-event-batch-to-webhook-events.spec.ts | 49 +++++ .../services/timeline-activity.service.ts | 69 +++++- ...ow-database-event-trigger.listener.spec.ts | 98 +++++++++ ...orkflow-database-event-trigger.listener.ts | 10 +- 14 files changed, 670 insertions(+), 137 deletions(-) rename packages/twenty-front/src/modules/object-record/record-board/hooks/{useGetShouldInitializeRecordBoardForUpdateInputs.ts => useGetRecordBoardEffectsForUpdateInputs.ts} (85%) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useRepositionRecordsOnBoard.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardUpdateEffect.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx index fa13327fe2..a9165693dd 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx index d17b2eebc3..f59acbdb6c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx @@ -81,7 +81,7 @@ export const RecordBoardQueryEffect = () => { !recordIndexRecordGroupsAreInInitialLoading && (queryIdentifierHasChanged || recordGroupIdsHaveChanged) ) { - triggerRecordBoardInitialQuery(); + triggerRecordBoardInitialQuery({ shouldResetScroll: true }); setLastRecordGroupIds(recordGroupIds); } else if ( !recordIndexRecordGroupsAreInInitialLoading && diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useGetRecordBoardEffectsForUpdateInputs.ts similarity index 85% rename from packages/twenty-front/src/modules/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs.ts rename to packages/twenty-front/src/modules/object-record/record-board/hooks/useGetRecordBoardEffectsForUpdateInputs.ts index 650ef3c78e..c9babf1012 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useGetRecordBoardEffectsForUpdateInputs.ts @@ -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(); 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, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRepositionRecordsOnBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRepositionRecordsOnBoard.ts new file mode 100644 index 0000000000..4b86886813 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRepositionRecordsOnBoard.ts @@ -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 = {}; + + 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 = {}; + + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts index 3400c7a04d..6aa3bf742e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardUpdateEffect.ts b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardUpdateEffect.ts new file mode 100644 index 0000000000..ca399d4e69 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardUpdateEffect.ts @@ -0,0 +1,4 @@ +export type RecordBoardUpdateEffect = + | 'trigger-initial-query' + | 'reposition-records' + | 'none'; diff --git a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts index 7f182c6414..d92d71de4b 100644 --- a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts @@ -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 = + { + 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', () => { diff --git a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts index 37f25b12bf..a9a9d79667 100644 --- a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts +++ b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/object-record-changed-values.ts @@ -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 diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/__tests__/transform-event-batch-to-event-payloads.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/__tests__/transform-event-batch-to-event-payloads.spec.ts index 6884bb783b..483427c078 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/__tests__/transform-event-batch-to-event-payloads.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/__tests__/transform-event-batch-to-event-payloads.spec.ts @@ -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(); diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/transform-event-batch-to-event-payloads.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/transform-event-batch-to-event-payloads.ts index 9be21f1c98..62a39ac7ad 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/transform-event-batch-to-event-payloads.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/utils/transform-event-batch-to-event-payloads.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts index c59b694e43..2fa8dd5389 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/utils/__tests__/transform-event-batch-to-webhook-events.spec.ts @@ -245,4 +245,53 @@ describe('transformEventBatchToWebhookEvents', () => { expect(resultWithoutEventDate).toEqual(expectedResultWithoutEventDate); }); + + it('should include position-only update events', () => { + const workspaceEventBatch: WorkspaceEventBatch = { + 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']); + }); }); diff --git a/packages/twenty-server/src/modules/timeline/services/timeline-activity.service.ts b/packages/twenty-server/src/modules/timeline/services/timeline-activity.service.ts index 174f1e2b4c..39ba204439 100644 --- a/packages/twenty-server/src/modules/timeline/services/timeline-activity.service.ts +++ b/packages/twenty-server/src/modules/timeline/services/timeline-activity.service.ts @@ -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 { + 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, diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/__tests__/workflow-database-event-trigger.listener.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/__tests__/workflow-database-event-trigger.listener.spec.ts index 38fe4f713b..8baccfc812 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/__tests__/workflow-database-event-trigger.listener.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/__tests__/workflow-database-event-trigger.listener.spec.ts @@ -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 = { + ...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 = { + ...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 = { + ...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(); + }); }); }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/workflow-database-event-trigger.listener.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/workflow-database-event-trigger.listener.ts index 9f6ec3127e..724fb2cab0 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/workflow-database-event-trigger.listener.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/automated-trigger/listeners/workflow-database-event-trigger.listener.ts @@ -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)) ); }