refactor: metadata store cleanup, SSE unification, mock metadata loading & login redirect fix (#18651)

## Summary
- **SSE unification**: Replaced 11 individual SSE effect components with
a single generic `MetadataStoreSSEEffect`
- **Metadata store cleanup**: Merged `metadataCollectionHashesState`
into `metadataStoreState` (currentCollectionHash / draftCollectionHash
per entity), moved `objectMetadataItemsSelector` to `object-metadata`
domain, converted `navigationMenuItemsState` to a derived selector
- **Naming clarity**: Renamed `isAppMetadataReadyState` →
`isMinimalMetadataReadyState`, `MetadataGater` → `MinimalMetadataGater`,
`useIsLogged` → `useHasAccessTokenPair`,
`patchMetadataStoreFromSSEEvent` now takes named object params
- **Mock metadata loading**: Added `generate-navigation-menu-items.ts`
script, rewrote `useLoadMockedMinimalMetadata` to load full
objects/fields/indexes/views/navItems from generated mock data, enabling
proper sign-in background rendering (table columns, view picker,
navigation)
- **Login/logout transitions**: `MinimalMetadataLoadEffect` manages
mocked↔real metadata transitions based on auth state,
`MainContextStoreProvider` computes context on auth pages for view
picker support
- **Login redirect fix**: `handleLoadWorkspaceAfterAuthentication` now
re-enables `isAppEffectRedirectEnabled` after `loadCurrentUser()`
completes, fixing the blocked post-login navigation
- **Dead code removal**: Deleted `useRefreshPageLayouts`,
`useApplyPageLayouts`, `useStaleMetadataEntities`,
`metadataCollectionHashesState`, and all individual SSE effects

## Test plan
- [x] Login from welcome page redirects to companies page
- [x] Logout transitions cleanly to mocked metadata on welcome page
- [x] Sign-in background shows table columns, view picker, and
navigation items
- [x] SSE events still update metadata store entries correctly
- [x] Navigation menu items persist across page refreshes
- [ ] CI: lint, typecheck, tests pass
This commit is contained in:
Charles Bochet
2026-03-16 00:38:11 +01:00
committed by GitHub
parent 06efee1eef
commit ba9aa41bba
161 changed files with 3106 additions and 4350 deletions
@@ -1,9 +1,7 @@
import React from 'react';
import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { isMinimalMetadataReadyState } from '@/metadata-store/states/isMinimalMetadataReadyState';
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { UserContext } from '@/users/contexts/UserContext';
import { useLocation } from 'react-router-dom';
@@ -11,10 +9,8 @@ import { AppPath } from 'twenty-shared/types';
import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const MetadataGater = ({ children }: React.PropsWithChildren) => {
const isAppMetadataReady = useAtomStateValue(isAppMetadataReadyState);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const isLoggedIn = useIsLogged();
export const MinimalMetadataGater = ({ children }: React.PropsWithChildren) => {
const isMinimalMetadataReady = useAtomStateValue(isMinimalMetadataReadyState);
const location = useLocation();
const { dateFormat, timeFormat, timeZone } = useDateTimeFormat();
@@ -22,11 +18,12 @@ export const MetadataGater = ({ children }: React.PropsWithChildren) => {
const isOnExcludedPath =
isMatchingLocation(location, AppPath.Verify) ||
isMatchingLocation(location, AppPath.VerifyEmail) ||
isMatchingLocation(location, AppPath.SignInUp) ||
isMatchingLocation(location, AppPath.Invite) ||
isMatchingLocation(location, AppPath.ResetPassword) ||
isMatchingLocation(location, AppPath.CreateWorkspace);
const shouldShowLoader =
(!isAppMetadataReady && isLoggedIn && !isOnExcludedPath) ||
objectMetadataItems.length === 0;
const shouldShowLoader = !isMinimalMetadataReady && !isOnExcludedPath;
if (shouldShowLoader) {
return <UserOrMetadataLoader />;
@@ -1,33 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const FieldMetadataSSEEffect = () => {
const queryId = 'field-metadata-sse-effect';
const store = useStore();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.fieldMetadata,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.fieldMetadata,
onMetadataOperationBrowserEvent: (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'fieldMetadataItems',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
},
});
return null;
};
@@ -1,7 +1,7 @@
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import { isMinimalMetadataReadyState } from '@/metadata-store/states/isMinimalMetadataReadyState';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -10,8 +10,8 @@ import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
export const IsAppMetadataReadyEffect = () => {
const isLoggedIn = useIsLogged();
export const IsMinimalMetadataReadyEffect = () => {
const hasAccessTokenPair = useHasAccessTokenPair();
const currentUser = useAtomStateValue(currentUserState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const metadataStore = useAtomFamilyStateValue(
@@ -23,7 +23,9 @@ export const IsAppMetadataReadyEffect = () => {
metadataStoreState,
'views',
);
const setIsAppMetadataReady = useSetAtomState(isAppMetadataReadyState);
const setIsMinimalMetadataReady = useSetAtomState(
isMinimalMetadataReadyState,
);
useEffect(() => {
const hasActiveWorkspace = isWorkspaceActiveOrSuspended(currentWorkspace);
@@ -31,23 +33,26 @@ export const IsAppMetadataReadyEffect = () => {
const areObjectsLoaded = metadataStore.status === 'up-to-date';
const areViewsLoaded = metadataStoreViews.status === 'up-to-date';
const isReady = !areObjectsLoaded
? false
: !hasAccessTokenPair ||
(isDefined(currentUser) && (!hasActiveWorkspace || areViewsLoaded));
if (!areObjectsLoaded) {
setIsAppMetadataReady(false);
setIsMinimalMetadataReady(false);
return;
}
const isReady =
!isLoggedIn ||
(isDefined(currentUser) && (!hasActiveWorkspace || areViewsLoaded));
setIsAppMetadataReady(isReady);
if (isReady) {
setIsMinimalMetadataReady(true);
}
}, [
isLoggedIn,
hasAccessTokenPair,
currentUser,
currentWorkspace,
metadataStore.status,
metadataStoreViews.status,
setIsAppMetadataReady,
setIsMinimalMetadataReady,
]);
return null;
@@ -1,11 +0,0 @@
import { ObjectMetadataProviderInitialEffect } from '@/metadata-store/effect-components/ObjectMetadataProviderInitialEffect';
import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect';
import { ViewMetadataProviderInitialEffect } from '@/metadata-store/effect-components/ViewMetadataProviderInitialEffect';
export const MetadataProviderInitialEffects = () => (
<>
<UserMetadataProviderInitialEffect />
<ObjectMetadataProviderInitialEffect />
<ViewMetadataProviderInitialEffect />
</>
);
@@ -0,0 +1,28 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { mapAllMetadataNameToEntityKey } from '@/metadata-store/utils/mapAllMetadataNameToEntityKey';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
export const MetadataStoreSSEEffect = () => {
const store = useStore();
useListenToMetadataOperationBrowserEvent({
onMetadataOperationBrowserEvent: (eventDetail) => {
const entityKey = mapAllMetadataNameToEntityKey(eventDetail.metadataName);
if (!isDefined(entityKey)) {
return;
}
patchMetadataStoreFromSSEEvent({
store,
entityKey,
operation: eventDetail.operation,
updatedCollectionHash: eventDetail.updatedCollectionHash,
});
},
});
return null;
};
@@ -1,62 +1,73 @@
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useLoadMinimalMetadata } from '@/metadata-store/hooks/useLoadMinimalMetadata';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { useLoadMockedMinimalMetadata } from '@/metadata-store/hooks/useLoadMockedMinimalMetadata';
import { useLoadStaleMetadataEntities } from '@/metadata-store/hooks/useLoadStaleMetadataEntities';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useEffect, useState } from 'react';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
type LoadedState = 'none' | 'mocked' | 'real';
const computeDesiredLoadState = (
hasAccessTokenPair: boolean,
isActiveWorkspace: boolean,
): LoadedState => {
if (hasAccessTokenPair && isActiveWorkspace) {
return 'real';
}
return 'mocked';
};
export const MinimalMetadataLoadEffect = () => {
const isLoggedIn = useIsLogged();
const hasAccessTokenPair = useHasAccessTokenPair();
const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const store = useStore();
const [hasLoaded, setHasLoaded] = useState(false);
const [loadedState, setLoadedState] = useState<LoadedState>('none');
const { loadMinimalMetadata } = useLoadMinimalMetadata();
const { loadMockedMinimalMetadata } = useLoadMockedMinimalMetadata();
const { loadStaleMetadataEntities } = useLoadStaleMetadataEntities();
const isActiveWorkspace = isWorkspaceActiveOrSuspended(currentWorkspace);
const desiredLoadState = computeDesiredLoadState(
hasAccessTokenPair,
isActiveWorkspace,
);
useEffect(() => {
if (hasLoaded) {
if (!isCurrentUserLoaded || loadedState === desiredLoadState) {
return;
}
if (!isCurrentUserLoaded || !isLoggedIn) {
return;
}
setLoadedState(desiredLoadState);
if (!isWorkspaceActiveOrSuspended(currentWorkspace)) {
return;
}
const performLoad = async () => {
if (desiredLoadState === 'mocked') {
await loadMockedMinimalMetadata();
return;
}
const objectsEntry = store.get(
metadataStoreState.atomFamily('objectMetadataItems'),
);
const viewsEntry = store.get(metadataStoreState.atomFamily('views'));
const result = await loadMinimalMetadata();
const hasObjectsFromStorage = objectsEntry.status === 'up-to-date';
const hasViewsFromStorage = viewsEntry.status === 'up-to-date';
if (hasObjectsFromStorage && hasViewsFromStorage) {
setHasLoaded(true);
return;
}
const load = async () => {
await loadMinimalMetadata();
setHasLoaded(true);
if (result?.staleEntityKeys && result.staleEntityKeys.length > 0) {
await loadStaleMetadataEntities(result.staleEntityKeys);
}
};
load();
performLoad();
}, [
hasLoaded,
isCurrentUserLoaded,
isLoggedIn,
currentWorkspace,
store,
hasAccessTokenPair,
isActiveWorkspace,
desiredLoadState,
loadedState,
loadMinimalMetadata,
loadMockedMinimalMetadata,
loadStaleMetadataEntities,
]);
return null;
@@ -1,71 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { useApolloClient } from '@apollo/client/react';
import {
AllMetadataName,
FindManyNavigationMenuItemsDocument,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const NavigationMenuItemSSEEffect = () => {
const queryId = 'navigation-menu-item-sse-effect';
const store = useStore();
const { updateDraft, applyChanges } = useMetadataStore();
const client = useApolloClient();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.navigationMenuItem,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.navigationMenuItem,
onMetadataOperationBrowserEvent: async (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'navigationMenuItems',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
const result = await client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
});
if (!isDefined(result.data?.navigationMenuItems)) {
return;
}
const existingNavigationMenuItems = store.get(
navigationMenuItemsState.atom,
);
if (
!isDeeplyEqual(
existingNavigationMenuItems,
result.data.navigationMenuItems,
)
) {
store.set(
navigationMenuItemsState.atom,
result.data.navigationMenuItems,
);
updateDraft('navigationMenuItems', result.data.navigationMenuItems);
applyChanges();
}
},
});
return null;
};
@@ -1,61 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { navigationMenuItemsState } from '@/navigation-menu-item/states/navigationMenuItemsState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { useApolloClient } from '@apollo/client/react';
import {
AllMetadataName,
FindManyNavigationMenuItemsDocument,
} from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const ObjectMetadataItemSSEEffect = () => {
const queryId = 'object-metadata-sse-effect';
const store = useStore();
const client = useApolloClient();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.objectMetadata,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.objectMetadata,
onMetadataOperationBrowserEvent: async (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'objectMetadataItems',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
const navigationMenuItemsResult = await client.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
});
const existingNavigationMenuItems = store.get(
navigationMenuItemsState.atom,
);
if (
!isDeeplyEqual(
existingNavigationMenuItems,
navigationMenuItemsResult.data?.navigationMenuItems,
)
) {
store.set(
navigationMenuItemsState.atom,
navigationMenuItemsResult.data?.navigationMenuItems ?? [],
);
}
},
});
return null;
};
@@ -1,47 +0,0 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect, useState } from 'react';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
export const ObjectMetadataProviderInitialEffect = () => {
const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const [isInitialized, setIsInitialized] = useState(false);
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
useEffect(() => {
if (isInitialized) {
return;
}
if (!isCurrentUserLoaded) {
return;
}
const shouldLoadReal = isWorkspaceActiveOrSuspended(currentWorkspace);
const loadObjectMetadata = async () => {
if (shouldLoadReal) {
await refreshObjectMetadataItems();
} else {
await loadMockedObjectMetadataItems();
}
setIsInitialized(true);
};
loadObjectMetadata();
}, [
isInitialized,
isCurrentUserLoaded,
currentWorkspace,
refreshObjectMetadataItems,
loadMockedObjectMetadataItems,
]);
return null;
};
@@ -1,37 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const PageLayoutSSEEffect = () => {
const queryId = 'page-layout-metadata-sse-effect';
const store = useStore();
const { refreshPageLayouts } = useRefreshPageLayouts();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.pageLayout,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.pageLayout,
onMetadataOperationBrowserEvent: (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'pageLayouts',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
refreshPageLayouts();
},
});
return null;
};
@@ -1,37 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const PageLayoutTabSSEEffect = () => {
const queryId = 'page-layout-tab-sse-effect';
const store = useStore();
const { refreshPageLayouts } = useRefreshPageLayouts();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.pageLayoutTab,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.pageLayoutTab,
onMetadataOperationBrowserEvent: (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'pageLayoutTabs',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
refreshPageLayouts();
},
});
return null;
};
@@ -1,37 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { useRefreshPageLayouts } from '@/page-layout/hooks/useRefreshPageLayouts';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useStore } from 'jotai';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const PageLayoutWidgetSSEEffect = () => {
const queryId = 'page-layout-widget-sse-effect';
const store = useStore();
const { refreshPageLayouts } = useRefreshPageLayouts();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.pageLayoutWidget,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.pageLayoutWidget,
onMetadataOperationBrowserEvent: (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'pageLayoutWidgets',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
refreshPageLayouts();
},
});
return null;
};
@@ -1,4 +1,4 @@
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
@@ -27,7 +27,7 @@ import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
export const UserMetadataProviderInitialEffect = () => {
const isLoggedIn = useIsLogged();
const hasAccessTokenPair = useHasAccessTokenPair();
const currentUser = useAtomStateValue(currentUserState);
const store = useStore();
const [isInitialized, setIsInitialized] = useState(false);
@@ -65,7 +65,7 @@ export const UserMetadataProviderInitialEffect = () => {
[store],
);
const shouldSkipUserQuery = !isLoggedIn || isDefined(currentUser);
const shouldSkipUserQuery = !hasAccessTokenPair || isDefined(currentUser);
const { data: userQueryData, loading: userQueryLoading } = useQuery(
GetCurrentUserDocument,
@@ -79,7 +79,7 @@ export const UserMetadataProviderInitialEffect = () => {
return;
}
if (!isLoggedIn) {
if (!hasAccessTokenPair) {
setIsCurrentUserLoaded(true);
setIsInitialized(true);
return;
@@ -165,7 +165,7 @@ export const UserMetadataProviderInitialEffect = () => {
setIsInitialized(true);
}, [
isInitialized,
isLoggedIn,
hasAccessTokenPair,
userQueryLoading,
userQueryData?.currentUser,
setCurrentUser,
@@ -1,86 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
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 ViewFieldSSEEffect = () => {
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) => {
patchMetadataStoreFromSSEEvent(
store,
'viewFields',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
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;
};
@@ -1,73 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
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 } from '~/generated-metadata/graphql';
export const ViewFilterGroupSSEEffect = () => {
const store = useStore();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
useListenToEventsForQuery({
queryId: 'view-filter-groups-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewFilterGroup,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewFilterGroup,
onMetadataOperationBrowserEvent: (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'viewFilterGroups',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
const coreViews = store.get(coreViewsState.atom);
let viewId: string | undefined;
switch (eventDetail.operation.type) {
case 'create': {
viewId = (eventDetail.operation.createdRecord as { viewId?: string })
.viewId;
break;
}
case 'update': {
viewId = (eventDetail.operation.updatedRecord as { viewId?: string })
.viewId;
break;
}
case 'delete': {
const deletedId = eventDetail.operation.deletedRecordId as string;
viewId = coreViews.find((view) =>
view.viewFilterGroups?.some(
(viewFilterGroup) => viewFilterGroup.id === deletedId,
),
)?.id;
break;
}
}
if (!isDefined(viewId)) {
return;
}
const view = coreViews.find((coreView) => coreView.id === viewId);
if (isDefined(view)) {
refreshCoreViewsByObjectMetadataId(view.objectMetadataId);
}
},
});
return null;
};
@@ -1,71 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
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 } from '~/generated-metadata/graphql';
export const ViewFilterSSEEffect = () => {
const store = useStore();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
useListenToEventsForQuery({
queryId: 'view-filters-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewFilter,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewFilter,
onMetadataOperationBrowserEvent: (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'viewFilters',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
const coreViews = store.get(coreViewsState.atom);
let viewId: string | undefined;
switch (eventDetail.operation.type) {
case 'create': {
viewId = (eventDetail.operation.createdRecord as { viewId?: string })
.viewId;
break;
}
case 'update': {
viewId = (eventDetail.operation.updatedRecord as { viewId?: string })
.viewId;
break;
}
case 'delete': {
const deletedId = eventDetail.operation.deletedRecordId as string;
viewId = coreViews.find((view) =>
view.viewFilters.some((viewFilter) => viewFilter.id === deletedId),
)?.id;
break;
}
}
if (!isDefined(viewId)) {
return;
}
const view = coreViews.find((coreView) => coreView.id === viewId);
if (isDefined(view)) {
refreshCoreViewsByObjectMetadataId(view.objectMetadataId);
}
},
});
return null;
};
@@ -1,47 +0,0 @@
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useFetchAndLoadIndexViews } from '@/metadata-store/hooks/useFetchAndLoadIndexViews';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect, useState } from 'react';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
export const ViewMetadataProviderInitialEffect = () => {
const isLoggedIn = useIsLogged();
const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const [isInitialized, setIsInitialized] = useState(false);
const { fetchAndLoadIndexViews } = useFetchAndLoadIndexViews();
useEffect(() => {
if (isInitialized) {
return;
}
if (!isCurrentUserLoaded) {
return;
}
if (!isLoggedIn || !isWorkspaceActiveOrSuspended(currentWorkspace)) {
setIsInitialized(true);
return;
}
const initialize = async () => {
await fetchAndLoadIndexViews();
setIsInitialized(true);
};
initialize();
}, [
isInitialized,
isCurrentUserLoaded,
isLoggedIn,
currentWorkspace,
fetchAndLoadIndexViews,
]);
return null;
};
@@ -1,130 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useGetObjectMetadataItemIdFromViewRelationEventDetail } from '@/metadata-store/hooks/useGetObjectMetadataItemIdFromViewRelationEventDetail';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { type ViewField } from '@/views/types/ViewField';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { type ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { type ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const ViewRelationsSSEEffect = () => {
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
const { getObjectMetadataItemIdFromViewRelationEventDetail } =
useGetObjectMetadataItemIdFromViewRelationEventDetail();
useListenToEventsForQuery({
queryId: 'view-filters-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewFilter,
variables: {},
},
});
useListenToEventsForQuery({
queryId: 'view-sorts-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewSort,
variables: {},
},
});
useListenToEventsForQuery({
queryId: 'view-fields-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewField,
variables: {},
},
});
useListenToEventsForQuery({
queryId: 'view-filter-groups-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewFilterGroup,
variables: {},
},
});
// TODO: replace this with detailed SSE optimistic effects
const debouncedRefreshCoreViewsByObjectMetadataId = useDebouncedCallback(
(objectMetadataId: string) => {
refreshCoreViewsByObjectMetadataId(objectMetadataId);
},
500,
{
leading: false,
trailing: true,
},
);
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewFilter,
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<ViewFilter>,
) => {
const objectMetadataItemId =
getObjectMetadataItemIdFromViewRelationEventDetail(detail);
if (!isDefined(objectMetadataItemId)) {
return;
}
debouncedRefreshCoreViewsByObjectMetadataId(objectMetadataItemId);
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewSort,
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<ViewSort>,
) => {
const objectMetadataItemId =
getObjectMetadataItemIdFromViewRelationEventDetail(detail);
if (!isDefined(objectMetadataItemId)) {
return;
}
debouncedRefreshCoreViewsByObjectMetadataId(objectMetadataItemId);
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewFilterGroup,
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<ViewFilterGroup>,
) => {
const objectMetadataItemId =
getObjectMetadataItemIdFromViewRelationEventDetail(detail);
if (!isDefined(objectMetadataItemId)) {
return;
}
debouncedRefreshCoreViewsByObjectMetadataId(objectMetadataItemId);
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewField,
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<ViewField>,
) => {
const objectMetadataItemId =
getObjectMetadataItemIdFromViewRelationEventDetail(detail);
if (!isDefined(objectMetadataItemId)) {
return;
}
debouncedRefreshCoreViewsByObjectMetadataId(objectMetadataItemId);
},
});
return null;
};
@@ -1,93 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { coreViewsState } from '@/views/states/coreViewState';
import { type CoreViewWithoutRelations } from '@/views/types/CoreViewWithoutRelations';
import { useStore } from 'jotai';
import { AllMetadataName } from '~/generated-metadata/graphql';
import { sleep } from '~/utils/sleep';
export const ViewSSEEffect = () => {
const queryId = 'views-sse-effect';
const store = useStore();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
useListenToEventsForQuery({
queryId,
operationSignature: {
metadataName: AllMetadataName.view,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.view,
onMetadataOperationBrowserEvent: async (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'views',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
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: [],
},
]);
await sleep(50);
refreshCoreViewsByObjectMetadataId(createdView.objectMetadataId);
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;
};
@@ -1,71 +0,0 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { patchMetadataStoreFromSSEEvent } from '@/metadata-store/utils/patchMetadataStoreFromSSEEvent';
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 } from '~/generated-metadata/graphql';
export const ViewSortSSEEffect = () => {
const store = useStore();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
useListenToEventsForQuery({
queryId: 'view-sorts-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewSort,
variables: {},
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewSort,
onMetadataOperationBrowserEvent: (eventDetail) => {
patchMetadataStoreFromSSEEvent(
store,
'viewSorts',
eventDetail.operation,
eventDetail.updatedCollectionHash,
);
const coreViews = store.get(coreViewsState.atom);
let viewId: string | undefined;
switch (eventDetail.operation.type) {
case 'create': {
viewId = (eventDetail.operation.createdRecord as { viewId?: string })
.viewId;
break;
}
case 'update': {
viewId = (eventDetail.operation.updatedRecord as { viewId?: string })
.viewId;
break;
}
case 'delete': {
const deletedId = eventDetail.operation.deletedRecordId as string;
viewId = coreViews.find((view) =>
view.viewSorts.some((viewSort) => viewSort.id === deletedId),
)?.id;
break;
}
}
if (!isDefined(viewId)) {
return;
}
const view = coreViews.find((coreView) => coreView.id === viewId);
if (isDefined(view)) {
refreshCoreViewsByObjectMetadataId(view.objectMetadataId);
}
},
});
return null;
};
@@ -21,7 +21,10 @@ export const FIND_MINIMAL_METADATA = gql`
key
objectMetadataId
}
collectionHashes
collectionHashes {
collectionName
hash
}
}
}
`;
@@ -1,51 +0,0 @@
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useSetIndexViews } from '@/metadata-store/hooks/useSetIndexViews';
import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelated';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useApolloClient } from '@apollo/client/react';
import {
ViewType,
FindAllCoreViewsDocument,
} from '~/generated-metadata/graphql';
const INDEX_VIEW_TYPES = [ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR];
export const useFetchAndLoadIndexViews = () => {
const client = useApolloClient();
const { updateDraft, applyChanges } = useMetadataStore();
const { setIndexViews } = useSetIndexViews();
const fetchAndLoadIndexViews = useCallback(async () => {
const result = await client.query({
query: FindAllCoreViewsDocument,
variables: { viewTypes: INDEX_VIEW_TYPES },
fetchPolicy: 'network-only',
});
if (isDefined(result.data?.getCoreViews)) {
setIndexViews(result.data.getCoreViews);
const {
flatViews,
flatViewFields,
flatViewFilters,
flatViewSorts,
flatViewGroups,
flatViewFilterGroups,
flatViewFieldGroups,
} = splitViewWithRelated(result.data.getCoreViews);
updateDraft('views', flatViews);
updateDraft('viewFields', flatViewFields);
updateDraft('viewFilters', flatViewFilters);
updateDraft('viewSorts', flatViewSorts);
updateDraft('viewGroups', flatViewGroups);
updateDraft('viewFilterGroups', flatViewFilterGroups);
updateDraft('viewFieldGroups', flatViewFieldGroups);
applyChanges();
}
}, [client, setIndexViews, updateDraft, applyChanges]);
return { fetchAndLoadIndexViews };
};
@@ -1,39 +0,0 @@
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useGetViewIdFromViewSSERelationEventDetail } from '@/metadata-store/hooks/useGetViewIdFromViewSSERelationEventDetail';
import { useGetViewById } from '@/views/hooks/useGetViewById';
import { type ViewField } from '@/views/types/ViewField';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { type ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { type ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared/utils';
export const useGetObjectMetadataItemIdFromViewRelationEventDetail = () => {
const { getViewIdFromViewSSERelationEventDetail } =
useGetViewIdFromViewSSERelationEventDetail();
const { getViewById } = useGetViewById();
const getObjectMetadataItemIdFromViewRelationEventDetail = <
T extends ViewFilter | ViewFilterGroup | ViewField | ViewSort,
>(
eventDetail: MetadataOperationBrowserEventDetail<T>,
) => {
const viewId = getViewIdFromViewSSERelationEventDetail(eventDetail);
if (!isDefined(viewId)) {
return null;
}
const { view } = getViewById(viewId);
if (!isDefined(view)) {
return null;
}
return view.objectMetadataId;
};
return {
getObjectMetadataItemIdFromViewRelationEventDetail,
};
};
@@ -1,64 +0,0 @@
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { type ViewField } from '@/views/types/ViewField';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { type ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { type ViewSort } from '@/views/types/ViewSort';
import { type Nullable } from 'twenty-shared/types';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const useGetViewIdFromViewSSERelationEventDetail = () => {
const coreViews = useAtomStateValue(coreViewsState);
const getViewIdFromViewSSERelationEventDetail = <
T extends ViewFilter | ViewFilterGroup | ViewField | ViewSort,
>(
eventDetail: MetadataOperationBrowserEventDetail<T>,
) => {
switch (eventDetail.operation.type) {
case 'create': {
return eventDetail.operation.createdRecord?.viewId as Nullable<string>;
}
case 'update': {
return eventDetail.operation.updatedRecord?.viewId as Nullable<string>;
}
case 'delete': {
const deletedRecordId = eventDetail.operation.deletedRecordId;
switch (eventDetail.metadataName) {
case AllMetadataName.viewFilter:
return coreViews.find((view) =>
view.viewFilters.some(
(viewFilter) => viewFilter.id === deletedRecordId,
),
)?.id;
case AllMetadataName.viewField:
return coreViews.find((view) =>
view.viewFields.some(
(viewField) => viewField.id === deletedRecordId,
),
)?.id;
case AllMetadataName.viewFilterGroup:
return coreViews.find((view) =>
view.viewFilterGroups?.some(
(viewFilterGroup) => viewFilterGroup.id === deletedRecordId,
),
)?.id;
case AllMetadataName.viewSort:
return coreViews.find((view) =>
view.viewSorts.some(
(viewSort) => viewSort.id === deletedRecordId,
),
)?.id;
default:
return null;
}
}
}
};
return {
getViewIdFromViewSSERelationEventDetail,
};
};
@@ -1,5 +1,4 @@
import { FIND_MINIMAL_METADATA } from '@/metadata-store/graphql/queries/findMinimalMetadata';
import { metadataCollectionHashesState } from '@/metadata-store/states/metadataCollectionHashesState';
import {
metadataStoreState,
type MetadataEntityKey,
@@ -30,51 +29,56 @@ export const useLoadMinimalMetadata = () => {
const { objectMetadataItems, views, collectionHashes } =
result.data.minimalMetadata;
const localHashes = store.get(metadataCollectionHashesState.atom);
const serverHashes: Partial<Record<MetadataEntityKey, string>> = {};
const staleEntityKeys: MetadataEntityKey[] = [];
if (isDefined(collectionHashes)) {
for (const [metadataName, hash] of Object.entries(
collectionHashes as Record<string, string>,
)) {
const entityKey = mapAllMetadataNameToEntityKey(metadataName);
for (const { collectionName, hash } of collectionHashes) {
const entityKey = mapAllMetadataNameToEntityKey(collectionName);
if (isDefined(entityKey)) {
serverHashes[entityKey] = hash;
if (!isDefined(entityKey)) {
continue;
}
const entry = store.get(metadataStoreState.atomFamily(entityKey));
if (entry.currentCollectionHash !== hash) {
staleEntityKeys.push(entityKey);
}
store.set(metadataStoreState.atomFamily(entityKey), (prev) => ({
...prev,
draftCollectionHash: hash,
}));
}
}
store.set(metadataCollectionHashesState.atom, serverHashes);
const staleEntityKeys: MetadataEntityKey[] = Object.entries(serverHashes)
.filter(
([entityKey, hash]) =>
localHashes[entityKey as MetadataEntityKey] !== hash,
)
.map(([entityKey]) => entityKey as MetadataEntityKey);
const objectsEntry = store.get(
metadataStoreState.atomFamily('objectMetadataItems'),
);
if (objectsEntry.status === 'empty') {
store.set(metadataStoreState.atomFamily('objectMetadataItems'), {
current: objectMetadataItems as unknown as FlatObjectMetadataItem[],
draft: [],
status: 'up-to-date',
});
store.set(
metadataStoreState.atomFamily('objectMetadataItems'),
(prev) => ({
...prev,
current: objectMetadataItems as unknown as FlatObjectMetadataItem[],
status: 'up-to-date',
currentCollectionHash: prev.draftCollectionHash,
draftCollectionHash: undefined,
}),
);
}
const viewsEntry = store.get(metadataStoreState.atomFamily('views'));
if (viewsEntry.status === 'empty') {
store.set(metadataStoreState.atomFamily('views'), {
store.set(metadataStoreState.atomFamily('views'), (prev) => ({
...prev,
current: views as unknown as FlatView[],
draft: [],
status: 'up-to-date',
});
currentCollectionHash: prev.draftCollectionHash,
draftCollectionHash: undefined,
}));
}
return { staleEntityKeys };
@@ -0,0 +1,64 @@
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { splitObjectMetadataItemWithRelated } from '@/metadata-store/utils/splitObjectMetadataItemWithRelated';
import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelated';
import { useCallback } from 'react';
const MOCKED_COLLECTION_HASH = 'mocked';
export const useLoadMockedMinimalMetadata = () => {
const { updateDraft, applyChanges, resetMetadataStore } = useMetadataStore();
const loadMockedMinimalMetadata = useCallback(async () => {
resetMetadataStore();
const [
{ generatedMockObjectMetadataItems },
{ mockedCoreViews },
{ mockedNavigationMenuItems },
] = await Promise.all([
import('~/testing/utils/generatedMockObjectMetadataItems'),
import('~/testing/mock-data/generated/metadata/views/mock-views-data'),
import(
'~/testing/mock-data/generated/metadata/navigation-menu-items/mock-navigation-menu-items-data'
),
]);
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataItemWithRelated(generatedMockObjectMetadataItems);
updateDraft('objectMetadataItems', flatObjects, MOCKED_COLLECTION_HASH);
updateDraft('fieldMetadataItems', flatFields, MOCKED_COLLECTION_HASH);
updateDraft('indexMetadataItems', flatIndexes, MOCKED_COLLECTION_HASH);
const {
flatViews,
flatViewFields,
flatViewFilters,
flatViewSorts,
flatViewGroups,
flatViewFilterGroups,
flatViewFieldGroups,
} = splitViewWithRelated(mockedCoreViews);
updateDraft('views', flatViews, MOCKED_COLLECTION_HASH);
updateDraft('viewFields', flatViewFields, MOCKED_COLLECTION_HASH);
updateDraft('viewFilters', flatViewFilters, MOCKED_COLLECTION_HASH);
updateDraft('viewSorts', flatViewSorts, MOCKED_COLLECTION_HASH);
updateDraft('viewGroups', flatViewGroups, MOCKED_COLLECTION_HASH);
updateDraft(
'viewFilterGroups',
flatViewFilterGroups,
MOCKED_COLLECTION_HASH,
);
updateDraft('viewFieldGroups', flatViewFieldGroups, MOCKED_COLLECTION_HASH);
updateDraft(
'navigationMenuItems',
mockedNavigationMenuItems,
MOCKED_COLLECTION_HASH,
);
applyChanges();
}, [updateDraft, applyChanges, resetMetadataStore]);
return { loadMockedMinimalMetadata };
};
@@ -0,0 +1,206 @@
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { type MetadataEntityKey } from '@/metadata-store/states/metadataStoreState';
import { splitObjectMetadataItemWithRelated } from '@/metadata-store/utils/splitObjectMetadataItemWithRelated';
import { splitPageLayoutWithRelated } from '@/metadata-store/utils/splitPageLayoutWithRelated';
import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelated';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { mapPaginatedObjectMetadataItemsToObjectMetadataItems } from '@/object-metadata/utils/mapPaginatedObjectMetadataItemsToObjectMetadataItems';
import { transformPageLayout } from '@/page-layout/utils/transformPageLayout';
import { logicFunctionsState } from '@/settings/logic-functions/states/logicFunctionsState';
import { useApolloClient } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
FindAllCoreViewsDocument,
FindAllRecordPageLayoutsDocument,
FindFieldsWidgetCoreViewsDocument,
FindManyLogicFunctionsDocument,
FindManyNavigationMenuItemsDocument,
type ObjectMetadataItemsQuery,
ViewType,
} from '~/generated-metadata/graphql';
const OBJECTS_GROUP_KEYS: MetadataEntityKey[] = [
'objectMetadataItems',
'fieldMetadataItems',
'indexMetadataItems',
];
const VIEWS_GROUP_KEYS: MetadataEntityKey[] = [
'views',
'viewFields',
'viewFilters',
'viewSorts',
'viewGroups',
'viewFilterGroups',
'viewFieldGroups',
];
const PAGE_LAYOUTS_GROUP_KEYS: MetadataEntityKey[] = [
'pageLayouts',
'pageLayoutTabs',
'pageLayoutWidgets',
];
const INDEX_VIEW_TYPES = [ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR];
const FIELDS_WIDGET_VIEW_TYPES = [ViewType.FIELDS_WIDGET];
const hasOverlap = (
staleKeys: MetadataEntityKey[],
groupKeys: MetadataEntityKey[],
): boolean => staleKeys.some((key) => groupKeys.includes(key));
export const useLoadStaleMetadataEntities = () => {
const client = useApolloClient();
const store = useStore();
const { updateDraft, applyChanges } = useMetadataStore();
const loadStaleMetadataEntities = useCallback(
async (staleEntityKeys: MetadataEntityKey[]) => {
if (staleEntityKeys.length === 0) {
return;
}
const fetchPromises: Promise<void>[] = [];
if (hasOverlap(staleEntityKeys, OBJECTS_GROUP_KEYS)) {
fetchPromises.push(
client
.query<ObjectMetadataItemsQuery>({
query: FIND_MANY_OBJECT_METADATA_ITEMS,
fetchPolicy: 'network-only',
})
.then((result) => {
const compositeObjects =
mapPaginatedObjectMetadataItemsToObjectMetadataItems({
pagedObjectMetadataItems: result.data,
});
const { flatObjects, flatFields, flatIndexes } =
splitObjectMetadataItemWithRelated(compositeObjects);
updateDraft('objectMetadataItems', flatObjects);
updateDraft('fieldMetadataItems', flatFields);
updateDraft('indexMetadataItems', flatIndexes);
}),
);
}
if (hasOverlap(staleEntityKeys, VIEWS_GROUP_KEYS)) {
fetchPromises.push(
Promise.all([
client.query({
query: FindAllCoreViewsDocument,
variables: { viewTypes: INDEX_VIEW_TYPES },
fetchPolicy: 'network-only',
}),
client.query({
query: FindFieldsWidgetCoreViewsDocument,
variables: { viewTypes: FIELDS_WIDGET_VIEW_TYPES },
fetchPolicy: 'network-only',
}),
]).then(([indexViewsResult, fieldsWidgetViewsResult]) => {
const allViews = [
...(indexViewsResult.data?.getCoreViews ?? []),
...(fieldsWidgetViewsResult.data?.getCoreViews ?? []),
];
const {
flatViews,
flatViewFields,
flatViewFilters,
flatViewSorts,
flatViewGroups,
flatViewFilterGroups,
flatViewFieldGroups,
} = splitViewWithRelated(allViews);
updateDraft('views', flatViews);
updateDraft('viewFields', flatViewFields);
updateDraft('viewFilters', flatViewFilters);
updateDraft('viewSorts', flatViewSorts);
updateDraft('viewGroups', flatViewGroups);
updateDraft('viewFilterGroups', flatViewFilterGroups);
updateDraft('viewFieldGroups', flatViewFieldGroups);
}),
);
}
if (hasOverlap(staleEntityKeys, PAGE_LAYOUTS_GROUP_KEYS)) {
fetchPromises.push(
client
.query({
query: FindAllRecordPageLayoutsDocument,
fetchPolicy: 'network-only',
})
.then((result) => {
if (!isDefined(result.data?.getPageLayouts)) {
return;
}
const transformed =
result.data.getPageLayouts.map(transformPageLayout);
const {
flatPageLayouts,
flatPageLayoutTabs,
flatPageLayoutWidgets,
} = splitPageLayoutWithRelated(transformed);
updateDraft('pageLayouts', flatPageLayouts);
updateDraft('pageLayoutTabs', flatPageLayoutTabs);
updateDraft('pageLayoutWidgets', flatPageLayoutWidgets);
}),
);
}
if (staleEntityKeys.includes('logicFunctions')) {
fetchPromises.push(
client
.query({
query: FindManyLogicFunctionsDocument,
fetchPolicy: 'network-only',
})
.then((result) => {
if (!isDefined(result.data?.findManyLogicFunctions)) {
return;
}
store.set(
logicFunctionsState.atom,
result.data.findManyLogicFunctions,
);
updateDraft('logicFunctions', result.data.findManyLogicFunctions);
}),
);
}
if (staleEntityKeys.includes('navigationMenuItems')) {
fetchPromises.push(
client
.query({
query: FindManyNavigationMenuItemsDocument,
fetchPolicy: 'network-only',
})
.then((result) => {
if (!isDefined(result.data?.navigationMenuItems)) {
return;
}
updateDraft(
'navigationMenuItems',
result.data.navigationMenuItems,
);
}),
);
}
await Promise.all(fetchPromises);
applyChanges();
},
[client, store, updateDraft, applyChanges],
);
return { loadStaleMetadataEntities };
};
@@ -1,5 +1,4 @@
import { metadataCollectionHashesState } from '@/metadata-store/states/metadataCollectionHashesState';
import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import { isMinimalMetadataReadyState } from '@/metadata-store/states/isMinimalMetadataReadyState';
import {
ALL_METADATA_ENTITY_KEYS,
metadataStoreState,
@@ -37,8 +36,7 @@ export const resetMetadataStore = (store: JotaiStore) => {
store.set(metadataStoreState.atomFamily(key), EMPTY_ENTRY);
}
store.set(metadataCollectionHashesState.atom, {});
store.set(isAppMetadataReadyState.atom, false);
store.set(isMinimalMetadataReadyState.atom, false);
};
const changeMetadataEntityAsUpToDate = (
@@ -51,6 +49,9 @@ const changeMetadataEntityAsUpToDate = (
current: entry.draft,
draft: [],
status: 'up-to-date',
currentCollectionHash:
entry.draftCollectionHash ?? entry.currentCollectionHash,
draftCollectionHash: undefined,
});
};
@@ -58,7 +59,11 @@ export const useMetadataStore = () => {
const store = useStore();
const updateDraft = useCallback(
<K extends MetadataEntityKey>(key: K, data: MetadataEntityTypeMap[K][]) => {
<K extends MetadataEntityKey>(
key: K,
data: MetadataEntityTypeMap[K][],
collectionHash?: string,
) => {
const currentEntry = store.get(metadataStoreState.atomFamily(key));
if (
@@ -72,6 +77,9 @@ export const useMetadataStore = () => {
...prev,
draft: data,
status: 'draft-pending' as const,
...(collectionHash !== undefined
? { draftCollectionHash: collectionHash }
: {}),
}));
},
[store],
@@ -1,54 +0,0 @@
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { splitViewWithRelated } from '@/metadata-store/utils/splitViewWithRelated';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { coreViewsState } from '@/views/states/coreViewState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
export const useReloadWorkspaceMetadata = () => {
const store = useStore();
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
const { updateDraft, applyChanges, resetMetadataStore } = useMetadataStore();
const reloadWorkspaceMetadata = useCallback(async () => {
resetMetadataStore();
await refreshObjectMetadataItems();
const loadedViews = store.get(coreViewsState.atom);
const {
flatViews,
flatViewFields,
flatViewFilters,
flatViewSorts,
flatViewGroups,
flatViewFilterGroups,
flatViewFieldGroups,
} = splitViewWithRelated(loadedViews);
updateDraft('views', flatViews);
updateDraft('viewFields', flatViewFields);
updateDraft('viewFilters', flatViewFilters);
updateDraft('viewSorts', flatViewSorts);
updateDraft('viewGroups', flatViewGroups);
updateDraft('viewFilterGroups', flatViewFilterGroups);
updateDraft('viewFieldGroups', flatViewFieldGroups);
applyChanges();
}, [
resetMetadataStore,
refreshObjectMetadataItems,
store,
updateDraft,
applyChanges,
]);
const resetToMockedMetadata = useCallback(async () => {
resetMetadataStore();
await loadMockedObjectMetadataItems();
}, [resetMetadataStore, loadMockedObjectMetadataItems]);
return { reloadWorkspaceMetadata, resetToMockedMetadata };
};
@@ -1,29 +0,0 @@
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,
};
};
@@ -1,27 +0,0 @@
import { metadataCollectionHashesState } from '@/metadata-store/states/metadataCollectionHashesState';
import {
ALL_METADATA_ENTITY_KEYS,
type MetadataEntityKey,
} from '@/metadata-store/states/metadataStoreState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
// Returns the list of MetadataEntityKey whose local collection hash
// is missing or differs from the last known server collection hash.
export const useStaleMetadataEntities = (): MetadataEntityKey[] => {
const metadataCollectionHashes = useAtomStateValue(
metadataCollectionHashesState,
);
return useMemo(() => {
const staleKeys: MetadataEntityKey[] = [];
for (const entityKey of ALL_METADATA_ENTITY_KEYS) {
if (metadataCollectionHashes[entityKey] === undefined) {
staleKeys.push(entityKey);
}
}
return staleKeys;
}, [metadataCollectionHashes]);
};
@@ -1,6 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isAppMetadataReadyState = createAtomState<boolean>({
key: 'isAppMetadataReadyState',
export const isMinimalMetadataReadyState = createAtomState<boolean>({
key: 'isMinimalMetadataReadyState',
defaultValue: false,
});
@@ -1,11 +0,0 @@
import { type MetadataEntityKey } from '@/metadata-store/states/metadataStoreState';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const metadataCollectionHashesState = createAtomState<
Partial<Record<MetadataEntityKey, string>>
>({
key: 'metadataCollectionHashesState',
defaultValue: {},
useLocalStorage: true,
localStorageOptions: { getOnInit: true },
});
@@ -38,6 +38,8 @@ export type MetadataStoreItem = {
current: object[];
draft: object[];
status: MetadataEntityStoreStatus;
currentCollectionHash?: string;
draftCollectionHash?: string;
};
const METADATA_STORE_ITEM_INITIAL_VALUE: MetadataStoreItem = {
@@ -1,14 +0,0 @@
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatObjectMetadataItem } from '@/metadata-store/types/FlatObjectMetadataItem';
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const objectMetadataItemsSelector = createAtomSelector<
FlatObjectMetadataItem[]
>({
key: 'objectMetadataItemsSelector',
get: ({ get }) => {
const storeItem = get(metadataStoreState, 'objectMetadataItems');
return storeItem.current as FlatObjectMetadataItem[];
},
});
@@ -1,4 +1,3 @@
import { metadataCollectionHashesState } from '@/metadata-store/states/metadataCollectionHashesState';
import {
metadataStoreState,
type MetadataEntityKey,
@@ -22,19 +21,29 @@ type SSEEventOperation =
deletedRecordId: string;
};
export const patchMetadataStoreFromSSEEvent = (
store: JotaiStore,
entityKey: MetadataEntityKey,
operation: SSEEventOperation,
updatedCollectionHash?: string,
) => {
export const patchMetadataStoreFromSSEEvent = ({
store,
entityKey,
operation,
updatedCollectionHash,
}: {
store: JotaiStore;
entityKey: MetadataEntityKey;
operation: SSEEventOperation;
updatedCollectionHash?: string;
}) => {
const entry = store.get(metadataStoreState.atomFamily(entityKey));
const currentItems = entry.current as Array<{ id: string }>;
const hashUpdate = isDefined(updatedCollectionHash)
? { currentCollectionHash: updatedCollectionHash }
: {};
switch (operation.type) {
case 'create': {
store.set(metadataStoreState.atomFamily(entityKey), {
...entry,
...hashUpdate,
current: [...currentItems, operation.createdRecord],
});
break;
@@ -42,6 +51,7 @@ export const patchMetadataStoreFromSSEEvent = (
case 'update': {
store.set(metadataStoreState.atomFamily(entityKey), {
...entry,
...hashUpdate,
current: currentItems.map((item) =>
item.id === (operation.updatedRecord as { id: string }).id
? { ...item, ...operation.updatedRecord }
@@ -53,6 +63,7 @@ export const patchMetadataStoreFromSSEEvent = (
case 'delete': {
store.set(metadataStoreState.atomFamily(entityKey), {
...entry,
...hashUpdate,
current: currentItems.filter(
(item) => item.id !== operation.deletedRecordId,
),
@@ -60,13 +71,4 @@ export const patchMetadataStoreFromSSEEvent = (
break;
}
}
if (isDefined(updatedCollectionHash)) {
const currentHashes = store.get(metadataCollectionHashesState.atom);
store.set(metadataCollectionHashesState.atom, {
...currentHashes,
[entityKey]: updatedCollectionHash,
});
}
};