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
@@ -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))
);
}