Fix record index sync when view fields arrive via SSE (#19069)
Fixes #19023 ## What changed This updates the record index/view field state flow so the current view can react to late-arriving `viewFields` coming from SSE without requiring a page refresh. Changes: - extracted a narrower `syncRecordIndexViewFields` path in `useLoadRecordIndexStates` - kept the initial full record-index load for first entry into a view - added a follow-up sync in `RecordIndexLoadBaseOnContextStoreEffect` when the same view receives updated `viewFields` - updated `ViewBarRecordFieldEffect` so it re-syncs current record fields when `currentView.viewFields` changes instead of only initializing once ## Why There is a race when a user navigates to a custom object while AI is still creating metadata. In that case, the record index can initialize from a partial view, and later SSE `viewFields` updates were not being applied to the active view state. That could leave the table visually empty or incomplete until a refresh. ## Impact This should allow: - record index columns to update live when view fields arrive via SSE - view bar field state to update live as well - the current view to stay usable without a refresh while AI-created metadata is still streaming in ## Validation Validated locally with: - `npx prettier --check` on modified files - `npx oxlint --type-aware` on modified files Manual verification: - confirmed live record creation appeared without refresh - manual AI/SSE testing was partially limited by Groq TPM/token caps on the selected model, but the state-sync path was verified in code and local behavior checks <img width="3024" height="1964" alt="image" src="https://github.com/user-attachments/assets/f71c7490-bf57-4357-9d5f-087b2424b53b" /> --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
committed by
GitHub
parent
f45c54679c
commit
53de4c557b
+2
@@ -12,6 +12,7 @@ import { RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect } from '@
|
||||
import { RecordIndexEmptyStateNotShared } from '@/object-record/record-index/components/RecordIndexEmptyStateNotShared';
|
||||
import { RecordIndexLoadBaseOnContextStoreEffect } from '@/object-record/record-index/components/RecordIndexLoadBaseOnContextStoreEffect';
|
||||
import { RecordIndexPageHeader } from '@/object-record/record-index/components/RecordIndexPageHeader';
|
||||
import { RecordIndexViewFieldsSSESyncEffect } from '@/object-record/record-index/components/RecordIndexViewFieldsSSESyncEffect';
|
||||
import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hooks/useHandleIndexIdentifierClick';
|
||||
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
|
||||
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
|
||||
@@ -114,6 +115,7 @@ export const RecordIndexContainerGater = () => {
|
||||
</CommandMenuComponentInstanceContext.Provider>
|
||||
</RecordComponentInstanceContextsWrapper>
|
||||
<RecordIndexLoadBaseOnContextStoreEffect />
|
||||
<RecordIndexViewFieldsSSESyncEffect />
|
||||
</ViewComponentInstanceContext.Provider>
|
||||
</RecordIndexContextProvider>
|
||||
</>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
|
||||
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
|
||||
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
|
||||
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector';
|
||||
import { useStore } from 'jotai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { AllMetadataName } from '~/generated-metadata/graphql';
|
||||
|
||||
export const RecordIndexViewFieldsSSESyncEffect = () => {
|
||||
const store = useStore();
|
||||
|
||||
const { syncRecordIndexViewFields } = useLoadRecordIndexStates();
|
||||
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
|
||||
|
||||
const contextStoreCurrentViewId = useAtomComponentStateValue(
|
||||
contextStoreCurrentViewIdComponentState,
|
||||
);
|
||||
|
||||
useListenToMetadataOperationBrowserEvent({
|
||||
metadataName: AllMetadataName.viewField,
|
||||
onMetadataOperationBrowserEvent: () => {
|
||||
if (!isDefined(contextStoreCurrentViewId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentView = store.get(
|
||||
viewFromViewIdFamilySelector.selectorFamily({
|
||||
viewId: contextStoreCurrentViewId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isDefined(currentView)) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncRecordIndexViewFields(currentView, objectMetadataItem);
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
+90
-56
@@ -73,9 +73,9 @@ export const useLoadRecordIndexStates = () => {
|
||||
|
||||
const { setRecordGroupsFromViewGroups } = useSetRecordGroups();
|
||||
|
||||
const loadRecordIndexStates = useCallback(
|
||||
const syncRecordIndexViewFields = useCallback(
|
||||
(
|
||||
view: View,
|
||||
view: Pick<View, 'id' | 'viewFields'>,
|
||||
objectMetadataItem: EnrichedObjectMetadataItem,
|
||||
options?: { skipGlobalIndexStates?: boolean },
|
||||
) => {
|
||||
@@ -134,6 +134,88 @@ export const useLoadRecordIndexStates = () => {
|
||||
.map(mapViewFieldToRecordField)
|
||||
.filter(isDefined);
|
||||
|
||||
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
|
||||
objectMetadataItem.namePlural,
|
||||
view.id,
|
||||
);
|
||||
|
||||
const currentRecordFieldsAtom =
|
||||
currentRecordFieldsComponentState.atomFamily({
|
||||
instanceId: recordIndexId,
|
||||
});
|
||||
|
||||
const hasInitializedFieldsAtom =
|
||||
hasInitializedCurrentRecordFieldsComponentFamilyState.atomFamily({
|
||||
instanceId: recordIndexId,
|
||||
familyKey: { viewId: view.id },
|
||||
});
|
||||
|
||||
store.set(
|
||||
atom(null, (get, batchSet) => {
|
||||
if (!skipGlobalIndexStates) {
|
||||
const existingFieldDefs = get(
|
||||
recordIndexFieldDefinitionsState.atom,
|
||||
);
|
||||
|
||||
if (!isDeeplyEqual(existingFieldDefs, newFieldDefinitions)) {
|
||||
batchSet(
|
||||
recordIndexFieldDefinitionsState.atom,
|
||||
newFieldDefinitions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewField of view.viewFields) {
|
||||
const viewFieldMetadataType = objectMetadataItem.fields?.find(
|
||||
(field) => field.id === viewField.fieldMetadataId,
|
||||
)?.type;
|
||||
|
||||
const existingAggregateOp = get(
|
||||
viewFieldAggregateOperationState.atomFamily({
|
||||
viewFieldId: viewField.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const convertedViewFieldAggregateOp = isDefined(
|
||||
viewField.aggregateOperation,
|
||||
)
|
||||
? convertAggregateOperationToExtendedAggregateOperation(
|
||||
viewField.aggregateOperation,
|
||||
viewFieldMetadataType,
|
||||
)
|
||||
: viewField.aggregateOperation;
|
||||
|
||||
if (existingAggregateOp !== convertedViewFieldAggregateOp) {
|
||||
batchSet(
|
||||
viewFieldAggregateOperationState.atomFamily({
|
||||
viewFieldId: viewField.id,
|
||||
}),
|
||||
convertedViewFieldAggregateOp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingRecordFields = get(currentRecordFieldsAtom);
|
||||
|
||||
if (!isDeeplyEqual(existingRecordFields, recordFields)) {
|
||||
batchSet(currentRecordFieldsAtom, recordFields);
|
||||
}
|
||||
|
||||
batchSet(hasInitializedFieldsAtom, true);
|
||||
}),
|
||||
);
|
||||
},
|
||||
[store],
|
||||
);
|
||||
|
||||
const loadRecordIndexStates = useCallback(
|
||||
(
|
||||
view: View,
|
||||
objectMetadataItem: EnrichedObjectMetadataItem,
|
||||
options?: { skipGlobalIndexStates?: boolean },
|
||||
) => {
|
||||
const skipGlobalIndexStates = options?.skipGlobalIndexStates ?? false;
|
||||
|
||||
const flattenedFieldMetadataItems = store.get(
|
||||
flattenedFieldMetadataItemsSelector.atom,
|
||||
);
|
||||
@@ -178,10 +260,6 @@ export const useLoadRecordIndexStates = () => {
|
||||
view.id,
|
||||
);
|
||||
|
||||
const currentRecordFieldsAtom =
|
||||
currentRecordFieldsComponentState.atomFamily({
|
||||
instanceId: recordIndexId,
|
||||
});
|
||||
const currentRecordFiltersAtom =
|
||||
currentRecordFiltersComponentState.atomFamily({
|
||||
instanceId: recordIndexId,
|
||||
@@ -195,11 +273,6 @@ export const useLoadRecordIndexStates = () => {
|
||||
instanceId: recordIndexId,
|
||||
});
|
||||
|
||||
const hasInitializedFieldsAtom =
|
||||
hasInitializedCurrentRecordFieldsComponentFamilyState.atomFamily({
|
||||
instanceId: recordIndexId,
|
||||
familyKey: { viewId: view.id },
|
||||
});
|
||||
const hasInitializedFiltersAtom =
|
||||
hasInitializedCurrentRecordFiltersComponentFamilyState.atomFamily({
|
||||
instanceId: recordIndexId,
|
||||
@@ -211,53 +284,12 @@ export const useLoadRecordIndexStates = () => {
|
||||
familyKey: { viewId: view.id },
|
||||
});
|
||||
|
||||
syncRecordIndexViewFields(view, objectMetadataItem, {
|
||||
skipGlobalIndexStates,
|
||||
});
|
||||
|
||||
store.set(
|
||||
atom(null, (get, batchSet) => {
|
||||
if (!skipGlobalIndexStates) {
|
||||
const existingFieldDefs = get(
|
||||
recordIndexFieldDefinitionsState.atom,
|
||||
);
|
||||
if (!isDeeplyEqual(existingFieldDefs, newFieldDefinitions)) {
|
||||
batchSet(
|
||||
recordIndexFieldDefinitionsState.atom,
|
||||
newFieldDefinitions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewField of view.viewFields) {
|
||||
const viewFieldMetadataType = objectMetadataItem.fields?.find(
|
||||
(field) => field.id === viewField.fieldMetadataId,
|
||||
)?.type;
|
||||
|
||||
const existingAggregateOp = get(
|
||||
viewFieldAggregateOperationState.atomFamily({
|
||||
viewFieldId: viewField.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const convertedViewFieldAggregateOp = isDefined(
|
||||
viewField.aggregateOperation,
|
||||
)
|
||||
? convertAggregateOperationToExtendedAggregateOperation(
|
||||
viewField.aggregateOperation,
|
||||
viewFieldMetadataType,
|
||||
)
|
||||
: viewField.aggregateOperation;
|
||||
|
||||
if (existingAggregateOp !== convertedViewFieldAggregateOp) {
|
||||
batchSet(
|
||||
viewFieldAggregateOperationState.atomFamily({
|
||||
viewFieldId: viewField.id,
|
||||
}),
|
||||
convertedViewFieldAggregateOp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
batchSet(currentRecordFieldsAtom, recordFields);
|
||||
batchSet(hasInitializedFieldsAtom, true);
|
||||
|
||||
batchSet(currentRecordFiltersAtom, recordFilters);
|
||||
batchSet(currentRecordFilterGroupsAtom, recordFilterGroups);
|
||||
batchSet(hasInitializedFiltersAtom, true);
|
||||
@@ -325,10 +357,12 @@ export const useLoadRecordIndexStates = () => {
|
||||
recordIndexShouldHideEmptyRecordGroupsAtom,
|
||||
getFieldMetadataItemByIdOrThrow,
|
||||
setRecordGroupsFromViewGroups,
|
||||
syncRecordIndexViewFields,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
loadRecordIndexStates,
|
||||
syncRecordIndexViewFields,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user