From 2c02f2c499c97e93b9a9065d8f75eeb260288076 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Wed, 25 Feb 2026 13:43:56 +0100 Subject: [PATCH] Handle SSE event for View and ViewField (#18218) This PR implements a first working version of SSE for View and ViewField. We could improve the logic but we re-use the same actual codepath for refreshing the views of an object. This does not handle filters and sorts for now. # Demo https://github.com/user-attachments/assets/73367f3a-a5fc-4b06-a2e2-fad3b99deec7 --- .../components/MainContextStoreProvider.tsx | 4 +- .../IsAppMetadataReadyEffect.tsx | 11 +- .../ObjectMetadataProviderInitialEffect.tsx | 6 +- .../ViewFieldMetadataSSEEffect.tsx | 78 ++++++++++ .../ViewMetadataProviderInitialEffect.tsx | 62 ++------ .../ViewMetadataSSEEffect.tsx | 75 +++++++++ .../hooks/useFetchAndLoadIndexViews.ts | 31 ++++ .../metadata-store/hooks/useMetadataStore.ts | 67 +++++---- .../hooks/useReloadWorkspaceMetadata.ts | 4 +- .../metadata-store/hooks/useSetIndexViews.ts | 29 ++++ .../states/metadataStoreState.ts | 35 +++-- .../sse-db-event/components/SSEProvider.tsx | 4 + .../components/LazyMetadataLoadEffect.tsx | 2 +- .../internal/usePerformViewFieldAPIPersist.ts | 1 + .../useApplyCoreViewsForObjectMetadataId.ts | 142 ++++++++++++++++++ .../useRefreshCoreViewsByObjectMetadataId.ts | 139 +---------------- .../views/types/CoreViewWithoutRelations.ts | 11 ++ 17 files changed, 460 insertions(+), 241 deletions(-) create mode 100644 packages/twenty-front/src/modules/metadata-store/effect-components/ViewFieldMetadataSSEEffect.tsx create mode 100644 packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataSSEEffect.tsx create mode 100644 packages/twenty-front/src/modules/metadata-store/hooks/useFetchAndLoadIndexViews.ts create mode 100644 packages/twenty-front/src/modules/metadata-store/hooks/useSetIndexViews.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useApplyCoreViewsForObjectMetadataId.ts create mode 100644 packages/twenty-front/src/modules/views/types/CoreViewWithoutRelations.ts diff --git a/packages/twenty-front/src/modules/context-store/components/MainContextStoreProvider.tsx b/packages/twenty-front/src/modules/context-store/components/MainContextStoreProvider.tsx index 519a2dda7b..ab60d68611 100644 --- a/packages/twenty-front/src/modules/context-store/components/MainContextStoreProvider.tsx +++ b/packages/twenty-front/src/modules/context-store/components/MainContextStoreProvider.tsx @@ -1,5 +1,5 @@ -import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; import { MainContextStoreProviderEffect } from '@/context-store/components/MainContextStoreProviderEffect'; +import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; import { useLastVisitedView } from '@/navigation/hooks/useLastVisitedView'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; @@ -76,7 +76,7 @@ export const MainContextStoreProvider = () => { const shouldComputeContextStore = (isRecordIndexPage || isRecordShowPage || isSettingsPage) && !showAuthModal && - viewsEntry.status === 'loaded'; + viewsEntry.status === 'up-to-date'; if (!shouldComputeContextStore) { return null; diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/IsAppMetadataReadyEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/IsAppMetadataReadyEffect.tsx index e1474b8330..3a72877161 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/IsAppMetadataReadyEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/IsAppMetadataReadyEffect.tsx @@ -14,15 +14,18 @@ export const IsAppMetadataReadyEffect = () => { const isLoggedIn = useIsLogged(); const currentUser = useAtomStateValue(currentUserState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const objectsEntry = useAtomFamilyStateValue(metadataStoreState, 'objects'); + const objectMetadataItemsEntry = useAtomFamilyStateValue( + metadataStoreState, + 'objectMetadataItems', + ); const viewsEntry = useAtomFamilyStateValue(metadataStoreState, 'views'); const setIsAppMetadataReady = useSetAtomState(isAppMetadataReadyState); useEffect(() => { const hasActiveWorkspace = isWorkspaceActiveOrSuspended(currentWorkspace); - const areObjectsLoaded = objectsEntry.status === 'loaded'; - const areViewsLoaded = viewsEntry.status === 'loaded'; + const areObjectsLoaded = objectMetadataItemsEntry.status === 'up-to-date'; + const areViewsLoaded = viewsEntry.status === 'up-to-date'; if (!areObjectsLoaded) { setIsAppMetadataReady(false); @@ -38,7 +41,7 @@ export const IsAppMetadataReadyEffect = () => { isLoggedIn, currentUser, currentWorkspace, - objectsEntry.status, + objectMetadataItemsEntry.status, viewsEntry.status, setIsAppMetadataReady, ]); diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataProviderInitialEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataProviderInitialEffect.tsx index 37ac1367e5..201ba6b113 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataProviderInitialEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataProviderInitialEffect.tsx @@ -1,6 +1,6 @@ -import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore'; -import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState'; +import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore'; import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems'; import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; @@ -37,7 +37,7 @@ export const ObjectMetadataProviderInitialEffect = () => { } const loadedItems = store.get(objectMetadataItemsState.atom); - updateDraft('objects', loadedItems); + updateDraft('objectMetadataItems', loadedItems); applyChanges(); setIsInitialized(true); }; diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFieldMetadataSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFieldMetadataSSEEffect.tsx new file mode 100644 index 0000000000..8e685ad0e1 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewFieldMetadataSSEEffect.tsx @@ -0,0 +1,78 @@ +import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { useStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; +import { + AllMetadataName, + type CoreViewField, +} from '~/generated-metadata/graphql'; + +export const ViewFieldMetadataSSEEffect = () => { + const store = useStore(); + + const queryId = 'view-field-metadata-sse-effect'; + + const { refreshCoreViewsByObjectMetadataId } = + useRefreshCoreViewsByObjectMetadataId(); + + useListenToEventsForQuery({ + queryId, + operationSignature: { + metadataName: AllMetadataName.viewField, + variables: {}, + }, + }); + + useListenToMetadataOperationBrowserEvent({ + metadataName: AllMetadataName.viewField, + onMetadataOperationBrowserEvent: (eventDetail) => { + const coreViews = store.get(coreViewsState.atom); + + let viewId: string | undefined; + + switch (eventDetail.operation.type) { + case 'create': { + const createdViewField = eventDetail.operation + .createdRecord as unknown as CoreViewField; + viewId = createdViewField.viewId; + break; + } + case 'update': { + const updatedViewField = eventDetail.operation + .updatedRecord as unknown as CoreViewField; + viewId = updatedViewField.viewId; + break; + } + case 'delete': { + const deletedViewFieldId = eventDetail.operation + .deletedRecordId as string; + + const view = coreViews.find((coreView) => + coreView.viewFields.some( + (viewField) => viewField.id === deletedViewFieldId, + ), + ); + + viewId = view?.id; + break; + } + } + + if (!isDefined(viewId)) { + return; + } + + const view = coreViews.find((coreView) => coreView.id === viewId); + + if (!isDefined(view)) { + return; + } + + refreshCoreViewsByObjectMetadataId(view.objectMetadataId); + }, + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataProviderInitialEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataProviderInitialEffect.tsx index 83d09350b3..88c8f1893f 100644 --- a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataProviderInitialEffect.tsx +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataProviderInitialEffect.tsx @@ -1,51 +1,19 @@ -import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore'; import { useIsLogged } from '@/auth/hooks/useIsLogged'; -import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; -import { coreViewsState } from '@/views/states/coreViewState'; -import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations'; +import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState'; +import { useFetchAndLoadIndexViews } from '@/metadata-store/hooks/useFetchAndLoadIndexViews'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useStore } from 'jotai'; -import { useCallback, useEffect, useState } from 'react'; -import { isDefined } from 'twenty-shared/utils'; +import { useEffect, useState } from 'react'; import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace'; -import { - ViewType as CoreViewType, - useFindAllCoreViewsLazyQuery, -} from '~/generated-metadata/graphql'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; - -const INDEX_VIEW_TYPES = [ - CoreViewType.TABLE, - CoreViewType.KANBAN, - CoreViewType.CALENDAR, -]; export const ViewMetadataProviderInitialEffect = () => { const isLoggedIn = useIsLogged(); const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); - const store = useStore(); + const [isInitialized, setIsInitialized] = useState(false); - const { updateDraft, applyChanges } = useMetadataStore(); - - const [findAllCoreViews] = useFindAllCoreViewsLazyQuery(); - - const setIndexCoreViews = useCallback( - (indexViews: CoreViewWithRelations[]) => { - const existingCoreViews = store.get(coreViewsState.atom); - const existingFieldsWidgetViews = existingCoreViews.filter( - (view) => view.type === CoreViewType.FIELDS_WIDGET, - ); - const mergedViews = [...indexViews, ...existingFieldsWidgetViews]; - - if (!isDeeplyEqual(existingCoreViews, mergedViews)) { - store.set(coreViewsState.atom, mergedViews); - } - }, - [store], - ); + const { fetchAndLoadIndexViews } = useFetchAndLoadIndexViews(); useEffect(() => { if (isInitialized) { @@ -60,31 +28,19 @@ export const ViewMetadataProviderInitialEffect = () => { return; } - const loadViews = async () => { - const result = await findAllCoreViews({ - variables: { viewTypes: INDEX_VIEW_TYPES }, - fetchPolicy: 'network-only', - }); - - if (isDefined(result.data?.getCoreViews)) { - setIndexCoreViews(result.data.getCoreViews); - updateDraft('views', result.data.getCoreViews); - applyChanges(); - } + const initialize = async () => { + await fetchAndLoadIndexViews(); setIsInitialized(true); }; - loadViews(); + initialize(); }, [ isInitialized, isCurrentUserLoaded, isLoggedIn, currentWorkspace, - findAllCoreViews, - setIndexCoreViews, - updateDraft, - applyChanges, + fetchAndLoadIndexViews, ]); return null; diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataSSEEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataSSEEffect.tsx new file mode 100644 index 0000000000..7f736f0842 --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataSSEEffect.tsx @@ -0,0 +1,75 @@ +import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent'; +import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { type CoreViewWithoutRelations } from '@/views/types/CoreViewWithoutRelations'; +import { useStore } from 'jotai'; +import { AllMetadataName } from '~/generated-metadata/graphql'; + +export const ViewMetadataSSEEffect = () => { + const queryId = 'view-metadata-sse-effect'; + + const store = useStore(); + + useListenToEventsForQuery({ + queryId, + operationSignature: { + metadataName: AllMetadataName.view, + variables: {}, + }, + }); + + useListenToMetadataOperationBrowserEvent({ + metadataName: AllMetadataName.view, + onMetadataOperationBrowserEvent: (eventDetail) => { + switch (eventDetail.operation.type) { + case 'create': { + const createdView = eventDetail.operation + .createdRecord as unknown as CoreViewWithoutRelations; + + store.set(coreViewsState.atom, (prevCoreViews) => [ + ...prevCoreViews, + { + ...createdView, + viewFields: [], + viewFieldGroups: [], + viewFilterGroups: [], + viewSorts: [], + viewFilters: [], + viewGroups: [], + }, + ]); + break; + } + case 'update': { + const updatedView = eventDetail.operation + .updatedRecord as unknown as CoreViewWithoutRelations; + + store.set(coreViewsState.atom, (prevCoreViews) => + prevCoreViews.map((coreView) => + coreView.id === updatedView.id + ? { + ...coreView, + ...updatedView, + } + : coreView, + ), + ); + break; + } + case 'delete': { + const deletedViewId = eventDetail.operation.deletedRecordId as string; + + store.set(coreViewsState.atom, (prevCoreViews) => + prevCoreViews.filter((coreView) => coreView.id !== deletedViewId), + ); + + break; + } + default: + return; + } + }, + }); + + return null; +}; diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useFetchAndLoadIndexViews.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useFetchAndLoadIndexViews.ts new file mode 100644 index 0000000000..747343f46f --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useFetchAndLoadIndexViews.ts @@ -0,0 +1,31 @@ +import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore'; +import { useSetIndexViews } from '@/metadata-store/hooks/useSetIndexViews'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { + useFindAllCoreViewsLazyQuery, + ViewType, +} from '~/generated-metadata/graphql'; + +const INDEX_VIEW_TYPES = [ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR]; + +export const useFetchAndLoadIndexViews = () => { + const [findAllCoreViews] = useFindAllCoreViewsLazyQuery(); + const { updateDraft, applyChanges } = useMetadataStore(); + const { setIndexViews } = useSetIndexViews(); + + const fetchAndLoadIndexViews = useCallback(async () => { + const result = await findAllCoreViews({ + variables: { viewTypes: INDEX_VIEW_TYPES }, + fetchPolicy: 'network-only', + }); + + if (isDefined(result.data?.getCoreViews)) { + setIndexViews(result.data.getCoreViews); + updateDraft('views', result.data.getCoreViews); + applyChanges(); + } + }, [findAllCoreViews, setIndexViews, updateDraft, applyChanges]); + + return { fetchAndLoadIndexViews }; +}; diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts index 8c00c34b70..50f1fa08d2 100644 --- a/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts @@ -1,17 +1,17 @@ -import { - ALL_METADATA_KEYS, - metadataStoreState, - type MetadataKey, - type MetadataLoadEntry, -} from '@/metadata-store/states/metadataStoreState'; import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState'; -import { type createStore, useStore } from 'jotai'; +import { + ALL_METADATA_ENTITY_KEYS, + metadataStoreState, + type MetadataEntityKey, + type MetadataStoreItem, +} from '@/metadata-store/states/metadataStoreState'; +import { useStore, type createStore } from 'jotai'; import { useCallback } from 'react'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; type JotaiStore = ReturnType; -const EMPTY_ENTRY: MetadataLoadEntry = { +const EMPTY_ENTRY: MetadataStoreItem = { current: [], draft: [], status: 'empty', @@ -31,20 +31,23 @@ const areViewsConsistentWithObjects = ( }; export const resetMetadataStore = (store: JotaiStore) => { - for (const key of ALL_METADATA_KEYS) { + for (const key of ALL_METADATA_ENTITY_KEYS) { store.set(metadataStoreState.atomFamily(key), EMPTY_ENTRY); } store.set(isAppMetadataReadyState.atom, false); }; -const promoteEntry = (store: JotaiStore, key: MetadataKey) => { - const entry = store.get(metadataStoreState.atomFamily(key)); +const changeMetadataEntityAsUpToDate = ( + store: JotaiStore, + metadataEntityKey: MetadataEntityKey, +) => { + const entry = store.get(metadataStoreState.atomFamily(metadataEntityKey)); - store.set(metadataStoreState.atomFamily(key), { + store.set(metadataStoreState.atomFamily(metadataEntityKey), { current: entry.draft, draft: [], - status: 'loaded', + status: 'up-to-date', }); }; @@ -52,11 +55,11 @@ export const useMetadataStore = () => { const store = useStore(); const updateDraft = useCallback( - (key: MetadataKey, data: object[]) => { + (key: MetadataEntityKey, data: object[]) => { const currentEntry = store.get(metadataStoreState.atomFamily(key)); if ( - currentEntry.status === 'loaded' && + currentEntry.status === 'up-to-date' && isDeeplyEqual(currentEntry.current, data) ) { return; @@ -65,42 +68,48 @@ export const useMetadataStore = () => { store.set(metadataStoreState.atomFamily(key), (prev) => ({ ...prev, draft: data, - status: 'draft_pending' as const, + status: 'draft-pending' as const, })); }, [store], ); - const applyChanges = useCallback((): boolean => { - let promoted = false; + const applyChanges = useCallback((): { + hasPersistedAnyMetadataEntity: boolean; + } => { + let hasPersistedAnyMetadataEntity = false; - for (const key of ALL_METADATA_KEYS) { - if (key === 'views') { + for (const metadataEntityKey of ALL_METADATA_ENTITY_KEYS) { + if (metadataEntityKey === 'views') { continue; } - const entry = store.get(metadataStoreState.atomFamily(key)); + const metadataStoreEntityEntry = store.get( + metadataStoreState.atomFamily(metadataEntityKey), + ); - if (entry.status === 'draft_pending') { - promoteEntry(store, key); - promoted = true; + if (metadataStoreEntityEntry.status === 'draft-pending') { + changeMetadataEntityAsUpToDate(store, metadataEntityKey); + hasPersistedAnyMetadataEntity = true; } } const viewsEntry = store.get(metadataStoreState.atomFamily('views')); - if (viewsEntry.status === 'draft_pending') { - const objectsEntry = store.get(metadataStoreState.atomFamily('objects')); + if (viewsEntry.status === 'draft-pending') { + const objectsEntry = store.get( + metadataStoreState.atomFamily('objectMetadataItems'), + ); if ( areViewsConsistentWithObjects(viewsEntry.draft, objectsEntry.current) ) { - promoteEntry(store, 'views'); - promoted = true; + changeMetadataEntityAsUpToDate(store, 'views'); + hasPersistedAnyMetadataEntity = true; } } - return promoted; + return { hasPersistedAnyMetadataEntity }; }, [store]); const reset = useCallback(() => { diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useReloadWorkspaceMetadata.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useReloadWorkspaceMetadata.ts index 9594720858..760f9a9488 100644 --- a/packages/twenty-front/src/modules/metadata-store/hooks/useReloadWorkspaceMetadata.ts +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useReloadWorkspaceMetadata.ts @@ -17,7 +17,7 @@ export const useReloadWorkspaceMetadata = () => { await refreshObjectMetadataItems(); const loadedObjects = store.get(objectMetadataItemsState.atom); - updateDraft('objects', loadedObjects); + updateDraft('objectMetadataItems', loadedObjects); applyChanges(); const loadedViews = store.get(coreViewsState.atom); @@ -36,7 +36,7 @@ export const useReloadWorkspaceMetadata = () => { await loadMockedObjectMetadataItems(); const loadedObjects = store.get(objectMetadataItemsState.atom); - updateDraft('objects', loadedObjects); + updateDraft('objectMetadataItems', loadedObjects); applyChanges(); }, [ resetMetadataStore, diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useSetIndexViews.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useSetIndexViews.ts new file mode 100644 index 0000000000..a854006f9e --- /dev/null +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useSetIndexViews.ts @@ -0,0 +1,29 @@ +import { coreViewsState } from '@/views/states/coreViewState'; +import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { ViewType } from '~/generated-metadata/graphql'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const useSetIndexViews = () => { + const store = useStore(); + + const setIndexViews = useCallback( + (indexViews: CoreViewWithRelations[]) => { + const existingCoreViews = store.get(coreViewsState.atom); + const existingFieldsWidgetViews = existingCoreViews.filter( + (view) => view.type === ViewType.FIELDS_WIDGET, + ); + const mergedViews = [...indexViews, ...existingFieldsWidgetViews]; + + if (!isDeeplyEqual(existingCoreViews, mergedViews)) { + store.set(coreViewsState.atom, mergedViews); + } + }, + [store], + ); + + return { + setIndexViews, + }; +}; diff --git a/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts b/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts index 58a34cc8e6..d6decf8bcf 100644 --- a/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts +++ b/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts @@ -1,36 +1,39 @@ import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState'; -export type MetadataLoadStatus = 'empty' | 'draft_pending' | 'loaded'; +export type MetadataEntityStoreStatus = + | 'empty' + | 'draft-pending' + | 'up-to-date'; -export type MetadataKey = - | 'objects' - | 'views' - | 'pageLayouts' - | 'logicFunctions'; - -export const ALL_METADATA_KEYS: MetadataKey[] = [ - 'objects', +export const ALL_METADATA_ENTITY_KEYS = [ + 'objectMetadataItems', + 'fieldMetadataItems', 'views', + 'viewFields', + 'viewFilters', + 'viewSorts', 'pageLayouts', 'logicFunctions', -]; +] as const; -export type MetadataLoadEntry = { +export type MetadataEntityKey = (typeof ALL_METADATA_ENTITY_KEYS)[number]; + +export type MetadataStoreItem = { current: object[]; draft: object[]; - status: MetadataLoadStatus; + status: MetadataEntityStoreStatus; }; -const METADATA_LOAD_ENTRY_DEFAULT: MetadataLoadEntry = { +const METADATA_STORE_ITEM_INITIAL_VALUE: MetadataStoreItem = { current: [], draft: [], status: 'empty', }; export const metadataStoreState = createAtomFamilyState< - MetadataLoadEntry, - MetadataKey + MetadataStoreItem, + MetadataEntityKey >({ key: 'metadataStoreState', - defaultValue: METADATA_LOAD_ENTRY_DEFAULT, + defaultValue: METADATA_STORE_ITEM_INITIAL_VALUE, }); diff --git a/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx b/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx index 0e54b898e2..d268c43dff 100644 --- a/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx +++ b/packages/twenty-front/src/modules/sse-db-event/components/SSEProvider.tsx @@ -1,3 +1,5 @@ +import { ViewFieldMetadataSSEEffect } from '@/metadata-store/effect-components/ViewFieldMetadataSSEEffect'; +import { ViewMetadataSSEEffect } from '@/metadata-store/effect-components/ViewMetadataSSEEffect'; import { SSEClientEffect } from '@/sse-db-event/components/SSEClientEffect'; import { SSEEventStreamEffect } from '@/sse-db-event/components/SSEEventStreamEffect'; import { SSEQuerySubscribeEffect } from '@/sse-db-event/components/SSEQuerySubscribeEffect'; @@ -13,6 +15,8 @@ export const SSEProvider = ({ children }: SSEProviderProps) => { + + {children} ); diff --git a/packages/twenty-front/src/modules/users/components/LazyMetadataLoadEffect.tsx b/packages/twenty-front/src/modules/users/components/LazyMetadataLoadEffect.tsx index 619e11b488..af9b343e7d 100644 --- a/packages/twenty-front/src/modules/users/components/LazyMetadataLoadEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/LazyMetadataLoadEffect.tsx @@ -1,5 +1,5 @@ -import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore'; import { useIsLogged } from '@/auth/hooks/useIsLogged'; +import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore'; import { recordPageLayoutsState } from '@/page-layout/states/recordPageLayoutsState'; import { type PageLayout } from '@/page-layout/types/PageLayout'; import { transformPageLayout } from '@/page-layout/utils/transformPageLayout'; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewFieldAPIPersist.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewFieldAPIPersist.ts index 907b1f916e..0694bcc341 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewFieldAPIPersist.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewFieldAPIPersist.ts @@ -18,6 +18,7 @@ import { useDestroyCoreViewFieldMutation, useUpdateCoreViewFieldMutation, } from '~/generated-metadata/graphql'; + export const usePerformViewFieldAPIPersist = () => { const { triggerViewFieldOptimisticEffect } = useTriggerViewFieldOptimisticEffect(); diff --git a/packages/twenty-front/src/modules/views/hooks/useApplyCoreViewsForObjectMetadataId.ts b/packages/twenty-front/src/modules/views/hooks/useApplyCoreViewsForObjectMetadataId.ts new file mode 100644 index 0000000000..deeaf3dc0a --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useApplyCoreViewsForObjectMetadataId.ts @@ -0,0 +1,142 @@ +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { recordIndexShouldHideEmptyRecordGroupsComponentState } from '@/object-record/record-index/states/recordIndexShouldHideEmptyRecordGroupsComponentState'; +import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; +import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; +import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; +import { coreViewsByObjectMetadataIdFamilySelector } from '@/views/states/selectors/coreViewsByObjectMetadataIdFamilySelector'; +import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; +import { getFilterableFields } from '@/views/utils/getFilterableFields'; +import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordField'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; +import { useCallback } from 'react'; +import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils'; +import { type FindManyCoreViewsQuery } from '~/generated-metadata/graphql'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const useApplyCoreViewsForObjectMetadataId = () => { + const applyCoreViewsForObjectMetadataId = useCallback( + ( + objectMetadataId: string, + coreViewsFromResult: FindManyCoreViewsQuery['getCoreViews'], + ) => { + const objectMetadataItems = jotaiStore.get(objectMetadataItemsState.atom); + + const objectMetadataItem = objectMetadataItems.find( + (item) => item.id === objectMetadataId, + ); + + if (!isDefined(objectMetadataItem)) { + return; + } + + const coreViewsForObjectMetadataId = jotaiStore.get( + coreViewsByObjectMetadataIdFamilySelector.selectorFamily( + objectMetadataId, + ), + ); + + if (isDeeplyEqual(coreViewsForObjectMetadataId, coreViewsFromResult)) { + return; + } + + jotaiStore.set( + coreViewsByObjectMetadataIdFamilySelector.selectorFamily( + objectMetadataId, + ), + coreViewsFromResult, + ); + + for (const coreView of coreViewsFromResult) { + const existingView = coreViewsForObjectMetadataId.find( + (coreViewForObjectMetadata) => + coreViewForObjectMetadata.id === coreView.id, + ); + + if (!isDefined(existingView)) { + continue; + } + + if ( + !isDeeplyEqual( + coreView.viewFields.map((viewField) => + removePropertiesFromRecord(viewField, ['updatedAt', 'createdAt']), + ), + existingView.viewFields, + ) + ) { + const view = convertCoreViewToView(coreView); + jotaiStore.set( + currentRecordFieldsComponentState.atomFamily({ + instanceId: getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + view.id, + ), + }), + view.viewFields + .filter(isDefined) + .map((viewField) => mapViewFieldToRecordField(viewField)), + ); + } + + if ( + !isDeeplyEqual( + coreView.viewFilters.map((viewFilter) => + removePropertiesFromRecord(viewFilter, [ + 'createdAt', + 'updatedAt', + ]), + ), + existingView.viewFilters, + ) + ) { + const view = convertCoreViewToView(coreView); + jotaiStore.set( + currentRecordFiltersComponentState.atomFamily({ + instanceId: getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + view.id, + ), + }), + mapViewFiltersToFilters( + view.viewFilters, + getFilterableFields(objectMetadataItem), + ), + ); + } + + if (!isDeeplyEqual(coreView.viewSorts, existingView.viewSorts)) { + const view = convertCoreViewToView(coreView); + jotaiStore.set( + currentRecordSortsComponentState.atomFamily({ + instanceId: getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + view.id, + ), + }), + view.viewSorts, + ); + } + + if ( + coreView.shouldHideEmptyGroups !== existingView.shouldHideEmptyGroups + ) { + const view = convertCoreViewToView(coreView); + jotaiStore.set( + recordIndexShouldHideEmptyRecordGroupsComponentState.atomFamily({ + instanceId: getRecordIndexIdFromObjectNamePluralAndViewId( + objectMetadataItem.namePlural, + view.id, + ), + }), + view.shouldHideEmptyGroups, + ); + } + } + }, + [], + ); + + return { applyCoreViewsForObjectMetadataId }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useRefreshCoreViewsByObjectMetadataId.ts b/packages/twenty-front/src/modules/views/hooks/useRefreshCoreViewsByObjectMetadataId.ts index a193643173..9ca6072a1a 100644 --- a/packages/twenty-front/src/modules/views/hooks/useRefreshCoreViewsByObjectMetadataId.ts +++ b/packages/twenty-front/src/modules/views/hooks/useRefreshCoreViewsByObjectMetadataId.ts @@ -1,24 +1,12 @@ -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState'; -import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; -import { recordIndexShouldHideEmptyRecordGroupsComponentState } from '@/object-record/record-index/states/recordIndexShouldHideEmptyRecordGroupsComponentState'; -import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; -import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; -import { coreViewsByObjectMetadataIdFamilySelector } from '@/views/states/selectors/coreViewsByObjectMetadataIdFamilySelector'; -import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView'; -import { getFilterableFields } from '@/views/utils/getFilterableFields'; - -import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordField'; -import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; +import { useApplyCoreViewsForObjectMetadataId } from '@/views/hooks/useApplyCoreViewsForObjectMetadataId'; import { useCallback } from 'react'; -import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils'; +import { isDefined } from 'twenty-shared/utils'; import { useFindManyCoreViewsLazyQuery } from '~/generated-metadata/graphql'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; -import { useStore } from 'jotai'; export const useRefreshCoreViewsByObjectMetadataId = () => { - const store = useStore(); const [findManyCoreViewsLazy] = useFindManyCoreViewsLazyQuery(); + const { applyCoreViewsForObjectMetadataId } = + useApplyCoreViewsForObjectMetadataId(); const refreshCoreViewsByObjectMetadataId = useCallback( async (objectMetadataId: string) => { @@ -33,123 +21,12 @@ export const useRefreshCoreViewsByObjectMetadataId = () => { return; } - const objectMetadataItems = store.get(objectMetadataItemsState.atom); - - const objectMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => objectMetadataItem.id === objectMetadataId, + applyCoreViewsForObjectMetadataId( + objectMetadataId, + result.data.getCoreViews, ); - - if (!isDefined(objectMetadataItem)) { - return; - } - - const coreViewsForObjectMetadataId = store.get( - coreViewsByObjectMetadataIdFamilySelector.selectorFamily( - objectMetadataId, - ), - ); - - const coreViewsFromResult = result.data.getCoreViews; - - if (isDeeplyEqual(coreViewsForObjectMetadataId, coreViewsFromResult)) { - return; - } - - store.set( - coreViewsByObjectMetadataIdFamilySelector.selectorFamily( - objectMetadataId, - ), - coreViewsFromResult, - ); - - for (const coreView of coreViewsFromResult) { - const existingView = coreViewsForObjectMetadataId.find( - (coreViewForObjectMetadata) => - coreViewForObjectMetadata.id === coreView.id, - ); - - if (!isDefined(existingView)) { - continue; - } - - if ( - !isDeeplyEqual( - coreView.viewFields.map((viewField) => - removePropertiesFromRecord(viewField, ['updatedAt', 'createdAt']), - ), - existingView.viewFields, - ) - ) { - const view = convertCoreViewToView(coreView); - store.set( - currentRecordFieldsComponentState.atomFamily({ - instanceId: getRecordIndexIdFromObjectNamePluralAndViewId( - objectMetadataItem.namePlural, - view.id, - ), - }), - view.viewFields - .filter(isDefined) - .map((viewField) => mapViewFieldToRecordField(viewField)), - ); - } - - if ( - !isDeeplyEqual( - coreView.viewFilters.map((viewFilter) => - removePropertiesFromRecord(viewFilter, [ - 'createdAt', - 'updatedAt', - ]), - ), - existingView.viewFilters, - ) - ) { - const view = convertCoreViewToView(coreView); - store.set( - currentRecordFiltersComponentState.atomFamily({ - instanceId: getRecordIndexIdFromObjectNamePluralAndViewId( - objectMetadataItem.namePlural, - view.id, - ), - }), - mapViewFiltersToFilters( - view.viewFilters, - getFilterableFields(objectMetadataItem), - ), - ); - } - - if (!isDeeplyEqual(coreView.viewSorts, existingView.viewSorts)) { - const view = convertCoreViewToView(coreView); - store.set( - currentRecordSortsComponentState.atomFamily({ - instanceId: getRecordIndexIdFromObjectNamePluralAndViewId( - objectMetadataItem.namePlural, - view.id, - ), - }), - view.viewSorts, - ); - } - - if ( - coreView.shouldHideEmptyGroups !== existingView.shouldHideEmptyGroups - ) { - const view = convertCoreViewToView(coreView); - store.set( - recordIndexShouldHideEmptyRecordGroupsComponentState.atomFamily({ - instanceId: getRecordIndexIdFromObjectNamePluralAndViewId( - objectMetadataItem.namePlural, - view.id, - ), - }), - view.shouldHideEmptyGroups, - ); - } - } }, - [findManyCoreViewsLazy, store], + [findManyCoreViewsLazy, applyCoreViewsForObjectMetadataId], ); return { diff --git a/packages/twenty-front/src/modules/views/types/CoreViewWithoutRelations.ts b/packages/twenty-front/src/modules/views/types/CoreViewWithoutRelations.ts new file mode 100644 index 0000000000..8299b61d0c --- /dev/null +++ b/packages/twenty-front/src/modules/views/types/CoreViewWithoutRelations.ts @@ -0,0 +1,11 @@ +import { type CoreView } from '~/generated-metadata/graphql'; + +export type CoreViewWithoutRelations = Exclude< + CoreView, + | 'viewFields' + | 'viewFieldGroups' + | 'viewGroups' + | 'viewFilters' + | 'viewFilterGroups' + | 'viewSorts' +>;