Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
321880828b | ||
|
|
629907292a | ||
|
|
2432479968 | ||
|
|
c15bcd3bec | ||
|
|
f91207eabc | ||
|
|
5b04676a51 | ||
|
|
ef4bcf186a | ||
|
|
b13aea14c1 | ||
|
|
41d825d2e5 | ||
|
|
c9da354177 | ||
|
|
9ecf28bb3c | ||
|
|
dc75165d27 | ||
|
|
c86732d902 | ||
|
|
7fb6074028 | ||
|
|
c7fc9bbd09 | ||
|
|
26eb28b792 | ||
|
|
c89d1a061d | ||
|
|
6d576ebbcd |
+36
-15
@@ -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,
|
||||
|
||||
+12
-11
@@ -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,
|
||||
};
|
||||
};
|
||||
+154
@@ -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,
|
||||
};
|
||||
};
|
||||
+116
-101
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type RecordBoardUpdateEffect =
|
||||
| 'trigger-initial-query'
|
||||
| 'reposition-records'
|
||||
| 'none';
|
||||
+50
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+91
@@ -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();
|
||||
|
||||
+5
-5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+49
@@ -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,
|
||||
|
||||
+98
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-6
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user