diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx
index 33cdabbaab..a634cf56fd 100644
--- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx
+++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx
@@ -1,6 +1,9 @@
import { AgentChatProvider } from '@/ai/components/AgentChatProvider';
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
+import { MetadataGater } from '@/metadata-store/components/MetadataGater';
+import { IsAppMetadataReadyEffect } from '@/metadata-store/effect-components/IsAppMetadataReadyEffect';
import { GotoHotkeysEffectsProvider } from '@/app/effect-components/GotoHotkeysEffectsProvider';
+import { MetadataProviderInitialEffects } from '@/metadata-store/effect-components/MetadataProviderInitialEffects';
import { PageChangeEffect } from '@/app/effect-components/PageChangeEffect';
import { AuthProvider } from '@/auth/components/AuthProvider';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
@@ -13,7 +16,7 @@ import { ErrorMessageEffect } from '@/error-handler/components/ErrorMessageEffec
import { PromiseRejectionEffect } from '@/error-handler/components/PromiseRejectionEffect';
import { HeadlessFrontComponentMountRoot } from '@/front-components/components/HeadlessFrontComponentMountRoot';
import { ApolloCoreProvider } from '@/object-metadata/components/ApolloCoreProvider';
-import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
+import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { SSEProvider } from '@/sse-db-event/components/SSEProvider';
import { SupportChatEffect } from '@/support/components/SupportChatEffect';
@@ -25,10 +28,7 @@ import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { UserThemeProviderEffect } from '@/ui/theme/components/UserThemeProviderEffect';
import { PageFavicon } from '@/ui/utilities/page-favicon/components/PageFavicon';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
-import { EagerMetadataLoadEffect } from '@/users/components/EagerMetadataLoadEffect';
import { LazyMetadataLoadEffect } from '@/users/components/LazyMetadataLoadEffect';
-import { MetadataProviderEffect } from '@/users/components/MetadataProviderEffect';
-import { UserProvider } from '@/users/components/UserProvider';
import { WorkspaceProviderEffect } from '@/workspace/components/WorkspaceProviderEffect';
import { StrictMode } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
@@ -42,19 +42,19 @@ export const AppRouterProviders = () => {
-
-
+
+
-
+
-
+
@@ -81,11 +81,11 @@ export const AppRouterProviders = () => {
-
+
-
+
diff --git a/packages/twenty-front/src/modules/app/hooks/useMetadataStore.ts b/packages/twenty-front/src/modules/app/hooks/useMetadataStore.ts
deleted file mode 100644
index 355bbc5938..0000000000
--- a/packages/twenty-front/src/modules/app/hooks/useMetadataStore.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import {
- metadataStoreState,
- type MetadataKey,
- type MetadataLoadEntry,
-} from '@/app/states/metadataStoreState';
-import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState';
-import { useStore } from 'jotai';
-import { useCallback } from 'react';
-
-const EMPTY_ENTRY: MetadataLoadEntry = {
- current: [],
- draft: [],
- status: 'empty',
-};
-
-// Views reference objects via objectMetadataId — both drafts must agree on IDs.
-const areDraftsConsistent = (
- objectsDraft: object[],
- viewsDraft: object[],
-): boolean => {
- const objectIds = new Set(
- objectsDraft.map((item) => (item as { id: string }).id),
- );
-
- return viewsDraft.every((view) =>
- objectIds.has((view as { objectMetadataId: string }).objectMetadataId),
- );
-};
-
-export const useMetadataStore = () => {
- const store = useStore();
-
- const updateDraft = useCallback(
- (key: MetadataKey, data: object[]) => {
- store.set(metadataStoreState.atomFamily(key), (prev) => ({
- ...prev,
- draft: data,
- status: 'draft_pending' as const,
- }));
- },
- [store],
- );
-
- // Validates consistency then atomically promotes all 'draft_pending' drafts
- // to current. If objects and views are inconsistent (e.g. mocked objects with
- // real views), resets the stale entries back to 'empty' so effects re-fetch.
- const applyChanges = useCallback(() => {
- const objectsEntry = store.get(metadataStoreState.atomFamily('objects'));
- const viewsEntry = store.get(metadataStoreState.atomFamily('views'));
-
- if (
- objectsEntry.status === 'draft_pending' &&
- viewsEntry.status === 'draft_pending' &&
- !areDraftsConsistent(objectsEntry.draft, viewsEntry.draft)
- ) {
- store.set(metadataStoreState.atomFamily('objects'), EMPTY_ENTRY);
- store.set(metadataStoreState.atomFamily('views'), EMPTY_ENTRY);
- return;
- }
-
- const allKeys: MetadataKey[] = [
- 'objects',
- 'views',
- 'pageLayouts',
- 'logicFunctions',
- ];
-
- for (const key of allKeys) {
- const entry = store.get(metadataStoreState.atomFamily(key));
-
- if (entry.status === 'draft_pending') {
- store.set(metadataStoreState.atomFamily(key), {
- current: entry.draft,
- draft: [],
- status: 'loaded',
- });
- }
- }
-
- store.set(shouldAppBeLoadingState.atom, false);
- }, [store]);
-
- return { updateDraft, applyChanges };
-};
diff --git a/packages/twenty-front/src/modules/app/states/isAppLoadingState.ts b/packages/twenty-front/src/modules/app/states/isAppLoadingState.ts
deleted file mode 100644
index 4b02ae86ba..0000000000
--- a/packages/twenty-front/src/modules/app/states/isAppLoadingState.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { metadataStoreState } from '@/app/states/metadataStoreState';
-import { createSelectorV2 } from '@/ui/utilities/state/jotai/utils/createSelectorV2';
-
-export const isAppLoadingState = createSelectorV2({
- key: 'isAppLoadingState',
- get: ({ get }) => {
- const objectsEntry = get(metadataStoreState, 'objects');
- const viewsEntry = get(metadataStoreState, 'views');
-
- return objectsEntry.status !== 'loaded' || viewsEntry.status !== 'loaded';
- },
-});
diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
index f61883e140..2ac3132fdb 100644
--- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
@@ -35,7 +35,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
-import { metadataStoreState } from '@/app/states/metadataStoreState';
+import { useReloadWorkspaceMetadata } from '@/metadata-store/hooks/useReloadWorkspaceMetadata';
import { lastAuthenticatedMethodState } from '@/auth/states/lastAuthenticatedMethodState';
import { loginTokenState } from '@/auth/states/loginTokenState';
import {
@@ -58,8 +58,6 @@ import { useOrigin } from '@/domain-manager/hooks/useOrigin';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
-import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
-import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
@@ -89,7 +87,8 @@ export const useAuth = () => {
);
const { loadCurrentUser } = useLoadCurrentUser();
- const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
+ const { reloadWorkspaceMetadata, resetToMockedMetadata } =
+ useReloadWorkspaceMetadata();
const { createWorkspace } = useSignUpInNewWorkspace();
const setSignInUpStep = useSetRecoilStateV2(signInUpStepState);
@@ -123,7 +122,6 @@ export const useAuth = () => {
const [, setSearchParams] = useSearchParams();
const navigate = useNavigate();
- const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
const clearSession = useRecoilCallback(
({ snapshot }) =>
@@ -161,26 +159,6 @@ export const useAuth = () => {
jotaiStore.set(workspaceAuthProvidersState.atom, authProvidersValue);
jotaiStore.set(workspacePublicDataState.atom, workspacePublicDataValue);
- jotaiStore.set(metadataStoreState.atomFamily('objects'), {
- current: [],
- draft: [],
- status: 'empty',
- });
- jotaiStore.set(metadataStoreState.atomFamily('views'), {
- current: [],
- draft: [],
- status: 'empty',
- });
- jotaiStore.set(metadataStoreState.atomFamily('pageLayouts'), {
- current: [],
- draft: [],
- status: 'empty',
- });
- jotaiStore.set(metadataStoreState.atomFamily('logicFunctions'), {
- current: [],
- draft: [],
- status: 'empty',
- });
jotaiStore.set(domainConfigurationState.atom, domainConfigurationValue);
jotaiStore.set(
isCaptchaScriptLoadedState.atom,
@@ -209,14 +187,14 @@ export const useAuth = () => {
await client.clearStore();
setLastAuthenticateWorkspaceDomain(null);
- await loadMockedObjectMetadataItems();
+ await resetToMockedMetadata();
navigate(AppPath.SignInUp);
},
[
goToRecoilSnapshot,
client,
setLastAuthenticateWorkspaceDomain,
- loadMockedObjectMetadataItems,
+ resetToMockedMetadata,
navigate,
],
);
@@ -344,19 +322,15 @@ export const useAuth = () => {
const handleLoadWorkspaceAfterAuthentication = useCallback(
async (authTokens: AuthTokenPair) => {
handleSetAuthTokens(authTokens);
-
setIsAppEffectRedirectEnabled(false);
- // TODO: We can't parallelize this yet because when loadCurrentUSer is loaded
- // then UserProvider updates its children and PrefetchDataProvider is then triggered
- // which requires the correct metadata to be loaded (not the mocks)
await loadCurrentUser();
- await refreshObjectMetadataItems();
+ await reloadWorkspaceMetadata();
},
[
loadCurrentUser,
handleSetAuthTokens,
- refreshObjectMetadataItems,
+ reloadWorkspaceMetadata,
setIsAppEffectRedirectEnabled,
],
);
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 b7222dc6e0..0800601af5 100644
--- a/packages/twenty-front/src/modules/context-store/components/MainContextStoreProvider.tsx
+++ b/packages/twenty-front/src/modules/context-store/components/MainContextStoreProvider.tsx
@@ -1,4 +1,4 @@
-import { metadataStoreState } from '@/app/states/metadataStoreState';
+import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { MainContextStoreProviderEffect } from '@/context-store/components/MainContextStoreProviderEffect';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { useLastVisitedView } from '@/navigation/hooks/useLastVisitedView';
diff --git a/packages/twenty-front/src/modules/users/components/UserProvider.tsx b/packages/twenty-front/src/modules/metadata-store/components/MetadataGater.tsx
similarity index 53%
rename from packages/twenty-front/src/modules/users/components/UserProvider.tsx
rename to packages/twenty-front/src/modules/metadata-store/components/MetadataGater.tsx
index f7f6031304..336647d39c 100644
--- a/packages/twenty-front/src/modules/users/components/UserProvider.tsx
+++ b/packages/twenty-front/src/modules/metadata-store/components/MetadataGater.tsx
@@ -1,8 +1,9 @@
import React from 'react';
-import { isAppLoadingState } from '@/app/states/isAppLoadingState';
+import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
+import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import { UserContext } from '@/users/contexts/UserContext';
import { useLocation } from 'react-router-dom';
@@ -10,23 +11,28 @@ import { AppPath } from 'twenty-shared/types';
import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
-export const UserProvider = ({ children }: React.PropsWithChildren) => {
- const isAppLoading = useRecoilValueV2(isAppLoadingState);
+export const MetadataGater = ({ children }: React.PropsWithChildren) => {
+ const isAppMetadataReady = useRecoilValueV2(isAppMetadataReadyState);
+ const objectMetadataItems = useRecoilValueV2(objectMetadataItemsState);
const isLoggedIn = useIsLogged();
const location = useLocation();
const { dateFormat, timeFormat, timeZone } = useDateTimeFormat();
- const shouldShowLoader =
- isAppLoading &&
- isLoggedIn &&
- !isMatchingLocation(location, AppPath.Verify) &&
- !isMatchingLocation(location, AppPath.VerifyEmail) &&
- !isMatchingLocation(location, AppPath.CreateWorkspace);
+ const isOnExcludedPath =
+ isMatchingLocation(location, AppPath.Verify) ||
+ isMatchingLocation(location, AppPath.VerifyEmail) ||
+ isMatchingLocation(location, AppPath.CreateWorkspace);
- return shouldShowLoader ? (
-
- ) : (
+ const shouldShowLoader =
+ (!isAppMetadataReady && isLoggedIn && !isOnExcludedPath) ||
+ objectMetadataItems.length === 0;
+
+ if (shouldShowLoader) {
+ return ;
+ }
+
+ return (
{
+ const isLoggedIn = useIsLogged();
+ const currentUser = useRecoilValueV2(currentUserState);
+ const currentWorkspace = useRecoilValueV2(currentWorkspaceState);
+ const objectsEntry = useFamilyRecoilValueV2(metadataStoreState, 'objects');
+ const viewsEntry = useFamilyRecoilValueV2(metadataStoreState, 'views');
+ const setIsAppMetadataReady = useSetRecoilStateV2(isAppMetadataReadyState);
+
+ console.log('objectsEntry', objectsEntry);
+ console.log('viewsEntry', viewsEntry);
+ console.log('isLoggedIn', isLoggedIn);
+ console.log('currentUser', currentUser);
+ console.log('currentWorkspace', currentWorkspace);
+ console.log('setIsAppMetadataReady', setIsAppMetadataReady);
+
+ useEffect(() => {
+ const hasActiveWorkspace = isWorkspaceActiveOrSuspended(currentWorkspace);
+
+ const areObjectsLoaded = objectsEntry.status === 'loaded';
+ const areViewsLoaded = viewsEntry.status === 'loaded';
+
+ if (!areObjectsLoaded) {
+ setIsAppMetadataReady(false);
+ return;
+ }
+
+ const isReady =
+ !isLoggedIn ||
+ (isDefined(currentUser) && (!hasActiveWorkspace || areViewsLoaded));
+
+ setIsAppMetadataReady(isReady);
+ }, [
+ isLoggedIn,
+ currentUser,
+ currentWorkspace,
+ objectsEntry.status,
+ viewsEntry.status,
+ setIsAppMetadataReady,
+ ]);
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/metadata-store/effect-components/MetadataProviderInitialEffects.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/MetadataProviderInitialEffects.tsx
new file mode 100644
index 0000000000..a787c23add
--- /dev/null
+++ b/packages/twenty-front/src/modules/metadata-store/effect-components/MetadataProviderInitialEffects.tsx
@@ -0,0 +1,11 @@
+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 = () => (
+ <>
+
+
+
+ >
+);
diff --git a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataProviderInitialEffect.tsx
similarity index 56%
rename from packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx
rename to packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataProviderInitialEffect.tsx
index a9c7569671..5f29c3e4f2 100644
--- a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx
+++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ObjectMetadataProviderInitialEffect.tsx
@@ -1,45 +1,54 @@
-import { currentUserState } from '@/auth/states/currentUserState';
+import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
+import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
-import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
-import { useEffect, useState } from 'react';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
+import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
+import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
+import { useStore } from 'jotai';
+import { useEffect, useState } from 'react';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
-import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
-export const ObjectMetadataItemsLoadEffect = () => {
- const currentUser = useRecoilValueV2(currentUserState);
+export const ObjectMetadataProviderInitialEffect = () => {
+ const isCurrentUserLoaded = useRecoilValueV2(isCurrentUserLoadedState);
const currentWorkspace = useRecoilValueV2(currentWorkspaceState);
+ const store = useStore();
const [isInitialized, setIsInitialized] = useState(false);
const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
+ const { updateDraft, applyChanges } = useMetadataStore();
useEffect(() => {
- if (isInitialized) {
- return;
- }
+ if (isInitialized) return;
+ if (!isCurrentUserLoaded) return;
+
+ const shouldLoadReal = isWorkspaceActiveOrSuspended(currentWorkspace);
const loadObjectMetadata = async () => {
- if (
- isUndefinedOrNull(currentUser) ||
- !isWorkspaceActiveOrSuspended(currentWorkspace)
- ) {
- await loadMockedObjectMetadataItems();
- } else {
+ if (shouldLoadReal) {
await refreshObjectMetadataItems();
+ } else {
+ await loadMockedObjectMetadataItems();
}
+
+ const loadedItems = store.get(objectMetadataItemsState.atom);
+ updateDraft('objects', loadedItems);
+ applyChanges();
setIsInitialized(true);
};
loadObjectMetadata();
}, [
- currentUser,
- currentWorkspace,
- loadMockedObjectMetadataItems,
- refreshObjectMetadataItems,
isInitialized,
+ isCurrentUserLoaded,
+ currentWorkspace,
+ refreshObjectMetadataItems,
+ loadMockedObjectMetadataItems,
+ store,
+ updateDraft,
+ applyChanges,
]);
- return <>>;
+ return null;
};
diff --git a/packages/twenty-front/src/modules/users/components/MetadataProviderEffect.tsx b/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx
similarity index 89%
rename from packages/twenty-front/src/modules/users/components/MetadataProviderEffect.tsx
rename to packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx
index ae7f6403bc..d25b3e2cd1 100644
--- a/packages/twenty-front/src/modules/users/components/MetadataProviderEffect.tsx
+++ b/packages/twenty-front/src/modules/metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx
@@ -1,5 +1,3 @@
-import { useRecoilCallback } from 'recoil';
-
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentUserState } from '@/auth/states/currentUserState';
@@ -8,6 +6,7 @@ import { currentWorkspaceDeletedMembersState } from '@/auth/states/currentWorksp
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
+import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useInitializeFormatPreferences } from '@/localization/hooks/useInitializeFormatPreferences';
import { getDateFnsLocale } from '@/ui/field/display/utils/getDateFnsLocale.util';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
@@ -15,7 +14,8 @@ import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetReco
import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { enUS } from 'date-fns/locale';
import { useStore } from 'jotai';
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
+import { useRecoilCallback } from 'recoil';
import { useLocation } from 'react-router-dom';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { AppPath, type ObjectPermissions } from 'twenty-shared/types';
@@ -29,10 +29,12 @@ import { dateLocaleStateV2 } from '~/localization/states/dateLocaleStateV2';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
-export const MetadataProviderEffect = () => {
+export const UserMetadataProviderInitialEffect = () => {
const location = useLocation();
-
+ const isLoggedIn = useIsLogged();
const currentUser = useRecoilValueV2(currentUserState);
+ const store = useStore();
+ const [isInitialized, setIsInitialized] = useState(false);
const setCurrentUser = useSetRecoilStateV2(currentUserState);
const setCurrentWorkspace = useSetRecoilStateV2(currentWorkspaceState);
@@ -40,10 +42,22 @@ export const MetadataProviderEffect = () => {
currentUserWorkspaceState,
);
const setAvailableWorkspaces = useSetRecoilStateV2(availableWorkspacesState);
- const { initializeFormatPreferences } = useInitializeFormatPreferences();
- const isLoggedIn = useIsLogged();
+ const setCurrentWorkspaceMember = useSetRecoilStateV2(
+ currentWorkspaceMemberState,
+ );
+ const setCurrentWorkspaceMembers = useSetRecoilStateV2(
+ currentWorkspaceMembersState,
+ );
+ const setCurrentWorkspaceMembersWithDeleted = useSetRecoilStateV2(
+ currentWorkspaceDeletedMembersState,
+ );
+ const setIsCurrentUserLoaded = useSetRecoilStateV2(isCurrentUserLoadedState);
- const store = useStore();
+ const { initializeFormatPreferences } = useInitializeFormatPreferences();
+
+ const isOnAuthPath =
+ isMatchingLocation(location, AppPath.Verify) ||
+ isMatchingLocation(location, AppPath.VerifyEmail);
const updateLocaleCatalog = useRecoilCallback(
({ snapshot, set }) =>
@@ -63,28 +77,23 @@ export const MetadataProviderEffect = () => {
[store],
);
- const setCurrentWorkspaceMember = useSetRecoilStateV2(
- currentWorkspaceMemberState,
- );
- const setCurrentWorkspaceMembers = useSetRecoilStateV2(
- currentWorkspaceMembersState,
- );
- const setCurrentWorkspaceMembersWithDeleted = useSetRecoilStateV2(
- currentWorkspaceDeletedMembersState,
- );
-
- const shouldSkip =
- !isLoggedIn ||
- isDefined(currentUser) ||
- isMatchingLocation(location, AppPath.Verify) ||
- isMatchingLocation(location, AppPath.VerifyEmail);
+ const shouldSkipUserQuery =
+ !isLoggedIn || isDefined(currentUser) || isOnAuthPath;
const { data: userQueryData, loading: userQueryLoading } =
useGetCurrentUserQuery({
- skip: shouldSkip,
+ skip: shouldSkipUserQuery,
});
useEffect(() => {
+ if (isInitialized) return;
+
+ if (!isLoggedIn) {
+ setIsCurrentUserLoaded(true);
+ setIsInitialized(true);
+ return;
+ }
+
if (userQueryLoading || !isDefined(userQueryData?.currentUser)) {
return;
}
@@ -160,7 +169,12 @@ export const MetadataProviderEffect = () => {
if (isDefined(availableWorkspaces)) {
setAvailableWorkspaces(availableWorkspaces);
}
+
+ setIsCurrentUserLoaded(true);
+ setIsInitialized(true);
}, [
+ isInitialized,
+ isLoggedIn,
userQueryLoading,
userQueryData?.currentUser,
setCurrentUser,
@@ -172,6 +186,7 @@ export const MetadataProviderEffect = () => {
initializeFormatPreferences,
setCurrentWorkspaceMembersWithDeleted,
updateLocaleCatalog,
+ setIsCurrentUserLoaded,
]);
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
new file mode 100644
index 0000000000..f4a48dedd7
--- /dev/null
+++ b/packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataProviderInitialEffect.tsx
@@ -0,0 +1,87 @@
+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 { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
+import { useStore } from 'jotai';
+import { useCallback, useEffect, useState } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+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 = useRecoilValueV2(isCurrentUserLoadedState);
+ const currentWorkspace = useRecoilValueV2(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],
+ );
+
+ useEffect(() => {
+ if (isInitialized) return;
+ if (!isCurrentUserLoaded) return;
+
+ if (!isLoggedIn || !isWorkspaceActiveOrSuspended(currentWorkspace)) {
+ setIsInitialized(true);
+ 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();
+ }
+
+ setIsInitialized(true);
+ };
+
+ loadViews();
+ }, [
+ isInitialized,
+ isCurrentUserLoaded,
+ isLoggedIn,
+ currentWorkspace,
+ findAllCoreViews,
+ setIndexCoreViews,
+ updateDraft,
+ applyChanges,
+ ]);
+
+ return null;
+};
diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts
new file mode 100644
index 0000000000..8c00c34b70
--- /dev/null
+++ b/packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts
@@ -0,0 +1,111 @@
+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 { useCallback } from 'react';
+import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
+
+type JotaiStore = ReturnType;
+
+const EMPTY_ENTRY: MetadataLoadEntry = {
+ current: [],
+ draft: [],
+ status: 'empty',
+};
+
+const areViewsConsistentWithObjects = (
+ viewsDraft: object[],
+ objectsCurrent: object[],
+): boolean => {
+ const objectIds = new Set(
+ objectsCurrent.map((item) => (item as { id: string }).id),
+ );
+
+ return viewsDraft.every((view) =>
+ objectIds.has((view as { objectMetadataId: string }).objectMetadataId),
+ );
+};
+
+export const resetMetadataStore = (store: JotaiStore) => {
+ for (const key of ALL_METADATA_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));
+
+ store.set(metadataStoreState.atomFamily(key), {
+ current: entry.draft,
+ draft: [],
+ status: 'loaded',
+ });
+};
+
+export const useMetadataStore = () => {
+ const store = useStore();
+
+ const updateDraft = useCallback(
+ (key: MetadataKey, data: object[]) => {
+ const currentEntry = store.get(metadataStoreState.atomFamily(key));
+
+ if (
+ currentEntry.status === 'loaded' &&
+ isDeeplyEqual(currentEntry.current, data)
+ ) {
+ return;
+ }
+
+ store.set(metadataStoreState.atomFamily(key), (prev) => ({
+ ...prev,
+ draft: data,
+ status: 'draft_pending' as const,
+ }));
+ },
+ [store],
+ );
+
+ const applyChanges = useCallback((): boolean => {
+ let promoted = false;
+
+ for (const key of ALL_METADATA_KEYS) {
+ if (key === 'views') {
+ continue;
+ }
+
+ const entry = store.get(metadataStoreState.atomFamily(key));
+
+ if (entry.status === 'draft_pending') {
+ promoteEntry(store, key);
+ promoted = true;
+ }
+ }
+
+ const viewsEntry = store.get(metadataStoreState.atomFamily('views'));
+
+ if (viewsEntry.status === 'draft_pending') {
+ const objectsEntry = store.get(metadataStoreState.atomFamily('objects'));
+
+ if (
+ areViewsConsistentWithObjects(viewsEntry.draft, objectsEntry.current)
+ ) {
+ promoteEntry(store, 'views');
+ promoted = true;
+ }
+ }
+
+ return promoted;
+ }, [store]);
+
+ const reset = useCallback(() => {
+ resetMetadataStore(store);
+ }, [store]);
+
+ return { updateDraft, applyChanges, resetMetadataStore: reset };
+};
diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useReloadWorkspaceMetadata.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useReloadWorkspaceMetadata.ts
new file mode 100644
index 0000000000..9594720858
--- /dev/null
+++ b/packages/twenty-front/src/modules/metadata-store/hooks/useReloadWorkspaceMetadata.ts
@@ -0,0 +1,50 @@
+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';
+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 loadedObjects = store.get(objectMetadataItemsState.atom);
+ updateDraft('objects', loadedObjects);
+ applyChanges();
+
+ const loadedViews = store.get(coreViewsState.atom);
+ updateDraft('views', loadedViews);
+ applyChanges();
+ }, [
+ resetMetadataStore,
+ refreshObjectMetadataItems,
+ store,
+ updateDraft,
+ applyChanges,
+ ]);
+
+ const resetToMockedMetadata = useCallback(async () => {
+ resetMetadataStore();
+
+ await loadMockedObjectMetadataItems();
+ const loadedObjects = store.get(objectMetadataItemsState.atom);
+ updateDraft('objects', loadedObjects);
+ applyChanges();
+ }, [
+ resetMetadataStore,
+ loadMockedObjectMetadataItems,
+ store,
+ updateDraft,
+ applyChanges,
+ ]);
+
+ return { reloadWorkspaceMetadata, resetToMockedMetadata };
+};
diff --git a/packages/twenty-front/src/modules/object-metadata/states/shouldAppBeLoadingState.ts b/packages/twenty-front/src/modules/metadata-store/states/isAppMetadataReadyState.ts
similarity index 52%
rename from packages/twenty-front/src/modules/object-metadata/states/shouldAppBeLoadingState.ts
rename to packages/twenty-front/src/modules/metadata-store/states/isAppMetadataReadyState.ts
index dcf7c2c697..b7d0eb598a 100644
--- a/packages/twenty-front/src/modules/object-metadata/states/shouldAppBeLoadingState.ts
+++ b/packages/twenty-front/src/modules/metadata-store/states/isAppMetadataReadyState.ts
@@ -1,6 +1,6 @@
import { createStateV2 } from '@/ui/utilities/state/jotai/utils/createStateV2';
-export const shouldAppBeLoadingState = createStateV2({
- key: 'shouldAppBeLoadingState',
+export const isAppMetadataReadyState = createStateV2({
+ key: 'isAppMetadataReadyState',
defaultValue: false,
});
diff --git a/packages/twenty-front/src/modules/app/states/metadataStoreState.ts b/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts
similarity index 84%
rename from packages/twenty-front/src/modules/app/states/metadataStoreState.ts
rename to packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts
index 3ea166d962..bca1686377 100644
--- a/packages/twenty-front/src/modules/app/states/metadataStoreState.ts
+++ b/packages/twenty-front/src/modules/metadata-store/states/metadataStoreState.ts
@@ -8,6 +8,13 @@ export type MetadataKey =
| 'pageLayouts'
| 'logicFunctions';
+export const ALL_METADATA_KEYS: MetadataKey[] = [
+ 'objects',
+ 'views',
+ 'pageLayouts',
+ 'logicFunctions',
+];
+
export type MetadataLoadEntry = {
current: object[];
draft: object[];
diff --git a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsProvider.tsx b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsProvider.tsx
deleted file mode 100644
index 167d4e774c..0000000000
--- a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsProvider.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-
-import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
-import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
-import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState';
-import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
-import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
-
-export const ObjectMetadataItemsProvider = ({
- children,
-}: React.PropsWithChildren) => {
- const objectMetadataItems = useRecoilValueV2(objectMetadataItemsState);
-
- const shouldAppBeLoading = useRecoilValueV2(shouldAppBeLoadingState);
-
- const shouldDisplayChildren =
- !shouldAppBeLoading && objectMetadataItems.length > 0;
-
- return (
- <>
- {shouldDisplayChildren ? (
-
- {children}
-
- ) : (
-
- )}
- >
- );
-};
diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata.ts
index 2939fe68be..a3b5630997 100644
--- a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata.ts
+++ b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata.ts
@@ -8,18 +8,11 @@ import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-met
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { useFamilySelectorValueV2 } from '@/ui/utilities/state/jotai/hooks/useFamilySelectorValueV2';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
+import { useMemo } from 'react';
export const useColumnDefinitionsFromObjectMetadata = (
objectMetadataItem: ObjectMetadataItem,
) => {
- const activeFieldMetadataItems = objectMetadataItem.readableFields.filter(
- (field) =>
- field.isActive &&
- // Allow label identifier field (e.g. id for junction tables) even if it's a hidden system field
- (!isHiddenSystemField(field) ||
- field.id === objectMetadataItem.labelIdentifierFieldMetadataId),
- );
-
const filterableFieldMetadataItems = useFamilySelectorValueV2(
availableFieldMetadataItemsForFilterFamilySelector,
{
@@ -34,10 +27,16 @@ export const useColumnDefinitionsFromObjectMetadata = (
},
);
- const restrictedFieldMetadataIds: string[] = [];
+ const columnDefinitions: ColumnDefinition[] = useMemo(() => {
+ const activeFieldMetadataItems =
+ objectMetadataItem.readableFields.filter(
+ (field) =>
+ field.isActive &&
+ (!isHiddenSystemField(field) ||
+ field.id === objectMetadataItem.labelIdentifierFieldMetadataId),
+ );
- const columnDefinitions: ColumnDefinition[] =
- activeFieldMetadataItems
+ return activeFieldMetadataItems
.map((field, index) =>
formatFieldMetadataItemAsColumnDefinition({
position: index,
@@ -47,6 +46,7 @@ export const useColumnDefinitionsFromObjectMetadata = (
)
.filter(filterAvailableTableColumns)
.filter((column) => {
+ const restrictedFieldMetadataIds: string[] = [];
return !restrictedFieldMetadataIds.includes(column.fieldMetadataId);
})
.map((column) => {
@@ -65,6 +65,11 @@ export const useColumnDefinitionsFromObjectMetadata = (
isSortable: existsInSortDefinitions,
};
});
+ }, [
+ filterableFieldMetadataItems,
+ sortableFieldMetadataItems,
+ objectMetadataItem,
+ ]);
return {
columnDefinitions,
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/DashboardWidgetPlaceholder.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/DashboardWidgetPlaceholder.stories.tsx
index 333d7f89d3..b5cbf4abb8 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/DashboardWidgetPlaceholder.stories.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/DashboardWidgetPlaceholder.stories.tsx
@@ -1,5 +1,5 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
-import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState';
+import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
import {
@@ -39,7 +39,7 @@ const meta: Meta = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/WidgetRenderer.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/WidgetRenderer.stories.tsx
index b49c59ac07..4859aa1973 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/WidgetRenderer.stories.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/components/__stories__/WidgetRenderer.stories.tsx
@@ -13,7 +13,7 @@ import { CatalogDecorator, type CatalogStory } from 'twenty-ui/testing';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
-import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState';
+import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
@@ -284,7 +284,7 @@ export const WithNumberChart: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(widget);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
@@ -372,7 +372,7 @@ export const WithGaugeChart: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(widget);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
@@ -464,7 +464,7 @@ export const WithBarChart: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(widget);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
@@ -559,7 +559,7 @@ export const SmallWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(widget);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
@@ -658,7 +658,7 @@ export const MediumWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(widget);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
@@ -757,7 +757,7 @@ export const LargeWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(widget);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
@@ -852,7 +852,7 @@ export const WideWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(widget);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
@@ -951,7 +951,7 @@ export const TallWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(widget);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
@@ -1046,7 +1046,7 @@ export const WithManyToOneRelationFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
PageLayoutType.RECORD_PAGE,
@@ -1163,7 +1163,7 @@ export const WithOneToManyRelationFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
PageLayoutType.RECORD_PAGE,
@@ -1272,7 +1272,7 @@ export const OneToManyRelationFieldWidgetWithSeeAllButton: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
PageLayoutType.RECORD_PAGE,
@@ -1408,7 +1408,7 @@ export const OnMobile: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
PageLayoutType.RECORD_PAGE,
@@ -1507,7 +1507,7 @@ export const InSidePanel: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
PageLayoutType.RECORD_PAGE,
@@ -1667,7 +1667,7 @@ export const Catalog: CatalogStory = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
if (state === 'hover') {
snapshot.set(
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx
index 084ac6f250..99741c7832 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx
@@ -10,7 +10,7 @@ import { expect, userEvent, waitFor, within } from 'storybook/test';
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
-import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState';
+import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilyStateV2 } from '@/object-record/record-store/states/recordStoreFamilyStateV2';
@@ -369,7 +369,7 @@ export const TextFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -467,7 +467,7 @@ export const AddressFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -568,7 +568,7 @@ export const NumberFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -666,7 +666,7 @@ export const LinkFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -764,7 +764,7 @@ export const ManyToOneRelationFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -873,7 +873,7 @@ export const OneToManyRelationFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -973,7 +973,7 @@ export const BooleanFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -1070,7 +1070,7 @@ export const CurrencyFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -1167,7 +1167,7 @@ export const EmailsFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
personObjectMetadataItem.id,
@@ -1265,7 +1265,7 @@ export const PhonesFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
personObjectMetadataItem.id,
@@ -1363,7 +1363,7 @@ export const SelectFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
opportunityObjectMetadataItem.id,
@@ -1466,7 +1466,7 @@ export const MultiSelectFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -1568,7 +1568,7 @@ export const TimelineActivityRelationFieldWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
timelineActivityObjectMetadataItem.id,
@@ -1676,7 +1676,7 @@ export const ManyToOneRelationCardWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -1793,7 +1793,7 @@ export const OneToManyRelationCardWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
@@ -1892,7 +1892,7 @@ export const TimelineActivityRelationCardWidget: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
timelineActivityObjectMetadataItem.id,
@@ -2062,7 +2062,7 @@ export const OneToManyRelationCardWidgetWithProgressiveLoading: Story = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
- jotaiStore.set(shouldAppBeLoadingState.atom, false);
+ jotaiStore.set(isAppMetadataReadyState.atom, true);
const pageLayoutData = createPageLayoutWithWidget(
widget,
companyObjectMetadataItem.id,
diff --git a/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonationAuth.ts b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonationAuth.ts
index 6d61f5e80c..857da0afa7 100644
--- a/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonationAuth.ts
+++ b/packages/twenty-front/src/modules/settings/admin-panel/hooks/useImpersonationAuth.ts
@@ -1,20 +1,16 @@
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { useAuth } from '@/auth/hooks/useAuth';
-import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState';
import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2';
export const useImpersonationAuth = () => {
const { getAuthTokensFromLoginToken } = useAuth();
- const setShouldAppBeLoading = useSetRecoilStateV2(shouldAppBeLoadingState);
const setIsAppEffectRedirectEnabled = useSetRecoilStateV2(
isAppEffectRedirectEnabledState,
);
const executeImpersonationAuth = async (loginToken: string) => {
- setShouldAppBeLoading(true);
setIsAppEffectRedirectEnabled(false);
await getAuthTokensFromLoginToken(loginToken);
- setShouldAppBeLoading(false);
setIsAppEffectRedirectEnabled(true);
};
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/MIGRATION_PLAN.md b/packages/twenty-front/src/modules/ui/utilities/state/jotai/MIGRATION_PLAN.md
index 7c0c57ec40..926afb6803 100644
--- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/MIGRATION_PLAN.md
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/MIGRATION_PLAN.md
@@ -207,7 +207,6 @@ Core metadata states used by record-table, record-board, views, etc.
```
object-metadata/states/lastFieldMetadataItemUpdateState.ts
object-metadata/states/objectMetadataItemsState.ts
-object-metadata/states/shouldAppBeLoadingState.ts
```
### Selectors (12 files)
diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createStateV2.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createStateV2.ts
index d12bc3a522..6a7d587544 100644
--- a/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createStateV2.ts
+++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/utils/createStateV2.ts
@@ -45,6 +45,7 @@ export const createStateV2 = ({
useCookieStorage.cookieKey,
defaultValue,
storage,
+ { getOnInit: true },
) as StateAtom;
} else if (useLocalStorage) {
baseAtom = atomWithStorage(
diff --git a/packages/twenty-front/src/modules/users/components/EagerMetadataLoadEffect.tsx b/packages/twenty-front/src/modules/users/components/EagerMetadataLoadEffect.tsx
deleted file mode 100644
index 8f38384b66..0000000000
--- a/packages/twenty-front/src/modules/users/components/EagerMetadataLoadEffect.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import { useMetadataStore } from '@/app/hooks/useMetadataStore';
-import { metadataStoreState } from '@/app/states/metadataStoreState';
-import { useIsLogged } from '@/auth/hooks/useIsLogged';
-import { currentUserState } from '@/auth/states/currentUserState';
-import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
-import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
-import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItems';
-import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
-import { useFamilyRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useFamilyRecoilValueV2';
-import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
-import { coreViewsState } from '@/views/states/coreViewState';
-import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations';
-import { useStore } from 'jotai';
-import { useCallback, useEffect } from 'react';
-import { useLocation } from 'react-router-dom';
-import { AppPath } from 'twenty-shared/types';
-import { isDefined } from 'twenty-shared/utils';
-import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
-import {
- ViewType as CoreViewType,
- useFindAllCoreViewsQuery,
-} from '~/generated-metadata/graphql';
-import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
-import { isMatchingLocation } from '~/utils/isMatchingLocation';
-
-const INDEX_VIEW_TYPES = [
- CoreViewType.TABLE,
- CoreViewType.KANBAN,
- CoreViewType.CALENDAR,
-];
-
-export const EagerMetadataLoadEffect = () => {
- const location = useLocation();
- const isLoggedIn = useIsLogged();
- const currentUser = useRecoilValueV2(currentUserState);
- const currentWorkspace = useRecoilValueV2(currentWorkspaceState);
- const store = useStore();
-
- const objectsEntry = useFamilyRecoilValueV2(metadataStoreState, 'objects');
- const viewsEntry = useFamilyRecoilValueV2(metadataStoreState, 'views');
-
- const { refreshObjectMetadataItems } = useRefreshObjectMetadataItems();
- const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
- const { updateDraft, applyChanges } = useMetadataStore();
-
- const isOnAuthPath =
- isMatchingLocation(location, AppPath.Verify) ||
- isMatchingLocation(location, AppPath.VerifyEmail);
-
- const { data: queryDataCoreViews } = useFindAllCoreViewsQuery({
- skip: !isLoggedIn || viewsEntry.status !== 'empty' || isOnAuthPath,
- variables: { viewTypes: INDEX_VIEW_TYPES },
- });
-
- 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],
- );
-
- useEffect(() => {
- if (objectsEntry.status !== 'empty') {
- return;
- }
-
- if (isLoggedIn && !isDefined(currentUser)) {
- return;
- }
-
- const loadObjectMetadata = async () => {
- if (!isLoggedIn || !isWorkspaceActiveOrSuspended(currentWorkspace)) {
- await loadMockedObjectMetadataItems();
- } else {
- await refreshObjectMetadataItems();
- }
-
- const loadedItems = store.get(objectMetadataItemsState.atom);
- updateDraft('objects', loadedItems);
- };
-
- loadObjectMetadata();
- }, [
- currentUser,
- currentWorkspace,
- isLoggedIn,
- loadMockedObjectMetadataItems,
- objectsEntry.status,
- refreshObjectMetadataItems,
- store,
- updateDraft,
- ]);
-
- useEffect(() => {
- if (!isDefined(queryDataCoreViews?.getCoreViews)) {
- return;
- }
-
- setIndexCoreViews(queryDataCoreViews.getCoreViews);
- updateDraft('views', queryDataCoreViews.getCoreViews);
- }, [queryDataCoreViews?.getCoreViews, setIndexCoreViews, updateDraft]);
-
- useEffect(() => {
- if (
- objectsEntry.status === 'draft_pending' &&
- viewsEntry.status === 'draft_pending'
- ) {
- applyChanges();
- }
- }, [objectsEntry.status, viewsEntry.status, applyChanges]);
-
- return null;
-};
diff --git a/packages/twenty-front/src/modules/users/components/LazyMetadataLoadEffect.tsx b/packages/twenty-front/src/modules/users/components/LazyMetadataLoadEffect.tsx
index fd757270c2..f7e074b840 100644
--- a/packages/twenty-front/src/modules/users/components/LazyMetadataLoadEffect.tsx
+++ b/packages/twenty-front/src/modules/users/components/LazyMetadataLoadEffect.tsx
@@ -1,4 +1,4 @@
-import { metadataStoreState } from '@/app/states/metadataStoreState';
+import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { recordPageLayoutsState } from '@/page-layout/states/recordPageLayoutsState';
import { type PageLayout } from '@/page-layout/types/PageLayout';
@@ -8,8 +8,8 @@ import { coreViewsState } from '@/views/states/coreViewState';
import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations';
import { useStore } from 'jotai';
import { useCallback, useEffect } from 'react';
-import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
+import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -29,6 +29,7 @@ export const LazyMetadataLoadEffect = () => {
const store = useStore();
const setLogicFunctions = useSetRecoilState(logicFunctionsState);
+ const { updateDraft, applyChanges } = useMetadataStore();
const isOnAuthPath =
isMatchingLocation(location, AppPath.Verify) ||
@@ -75,14 +76,8 @@ export const LazyMetadataLoadEffect = () => {
if (!isDeeplyEqual(existingRecordPageLayouts, recordPageLayouts)) {
set(recordPageLayoutsState, recordPageLayouts);
}
-
- store.set(metadataStoreState.atomFamily('pageLayouts'), {
- current: recordPageLayouts,
- draft: [],
- status: 'loaded',
- });
},
- [store],
+ [],
);
useEffect(() => {
@@ -102,7 +97,14 @@ export const LazyMetadataLoadEffect = () => {
queryDataRecordPageLayouts.getPageLayouts.map(transformPageLayout);
setRecordPageLayouts(transformedPageLayouts);
- }, [queryDataRecordPageLayouts?.getPageLayouts, setRecordPageLayouts]);
+ updateDraft('pageLayouts', transformedPageLayouts);
+ applyChanges();
+ }, [
+ queryDataRecordPageLayouts?.getPageLayouts,
+ setRecordPageLayouts,
+ updateDraft,
+ applyChanges,
+ ]);
useEffect(() => {
if (!isDefined(logicFunctionsData?.findManyLogicFunctions)) {
@@ -110,13 +112,14 @@ export const LazyMetadataLoadEffect = () => {
}
setLogicFunctions(logicFunctionsData.findManyLogicFunctions);
-
- store.set(metadataStoreState.atomFamily('logicFunctions'), {
- current: logicFunctionsData.findManyLogicFunctions,
- draft: [],
- status: 'loaded',
- });
- }, [logicFunctionsData?.findManyLogicFunctions, setLogicFunctions, store]);
+ updateDraft('logicFunctions', logicFunctionsData.findManyLogicFunctions);
+ applyChanges();
+ }, [
+ logicFunctionsData?.findManyLogicFunctions,
+ setLogicFunctions,
+ updateDraft,
+ applyChanges,
+ ]);
return null;
};
diff --git a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx
index 62615610df..8806b9c535 100644
--- a/packages/twenty-front/src/testing/decorators/PageDecorator.tsx
+++ b/packages/twenty-front/src/testing/decorators/PageDecorator.tsx
@@ -16,16 +16,16 @@ import { ClientConfigProviderEffect } from '@/client-config/components/ClientCon
import { ApolloCoreClientMockedProvider } from '@/object-metadata/hooks/__mocks__/ApolloCoreClientMockedProvider';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
+import { MetadataGater } from '@/metadata-store/components/MetadataGater';
+import { MetadataProviderInitialEffects } from '@/metadata-store/effect-components/MetadataProviderInitialEffects';
+import { IsAppMetadataReadyEffect } from '@/metadata-store/effect-components/IsAppMetadataReadyEffect';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
-import { MetadataProviderEffect } from '@/users/components/MetadataProviderEffect';
import { ClientConfigProvider } from '~/modules/client-config/components/ClientConfigProvider';
-import { UserProvider } from '~/modules/users/components/UserProvider';
import { mockedApolloClient } from '~/testing/mockedApolloClient';
import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider';
import { RecoilDebugObserverEffect } from '@/debug/components/RecoilDebugObserver';
-import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
-import { ObjectMetadataItemsProvider } from '@/object-metadata/components/ObjectMetadataItemsProvider';
+import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider';
import { SnackBarComponentInstanceContext } from '@/ui/feedback/snack-bar-manager/contexts/SnackBarComponentInstanceContext';
@@ -88,12 +88,12 @@ const Providers = () => {
-
+
+
-
+
-
-
+
@@ -105,10 +105,10 @@ const Providers = () => {
-
+
-
+
diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md
index 33f0a7dbbb..3b05ccceab 100644
--- a/packages/twenty-sdk/README.md
+++ b/packages/twenty-sdk/README.md
@@ -52,6 +52,7 @@ Commands:
auth:switch Switch the default workspace
auth:list List all configured workspaces
app:dev Watch and sync local application changes
+ app:typecheck Run TypeScript type checking on the application
app:uninstall Uninstall application from Twenty
entity:add Add a new entity to your application
function:logs Watch application function logs
@@ -124,6 +125,8 @@ Application development commands.
- `twenty app:dev [appPath]` — Start development mode: watch and sync local application changes.
- Behavior: Builds your application (functions and front components), computes the manifest, syncs everything to your workspace, then watches the directory for changes and re-syncs automatically. Displays an interactive UI showing build and sync status in real time. Press Ctrl+C to stop.
+- `twenty app:typecheck [appPath]` — Run TypeScript type checking on the application (runs `tsc --noEmit`). Exits with code 1 if type errors are found.
+
- `twenty app:uninstall [appPath]` — Uninstall the application from the current workspace.
### Entity
@@ -166,6 +169,9 @@ twenty app:dev
# Start dev mode with a custom workspace profile
twenty app:dev --workspace my-custom-workspace
+# Type check the application
+twenty app:typecheck
+
# Add a new entity interactively
twenty entity:add
diff --git a/packages/twenty-sdk/src/cli/commands/app-command.ts b/packages/twenty-sdk/src/cli/commands/app-command.ts
index b468c5c0eb..ae1fdc7a22 100644
--- a/packages/twenty-sdk/src/cli/commands/app-command.ts
+++ b/packages/twenty-sdk/src/cli/commands/app-command.ts
@@ -2,6 +2,7 @@ import { formatPath } from '@/cli/utilities/file/file-path';
import chalk from 'chalk';
import type { Command } from 'commander';
import { AppDevCommand } from './app/app-dev';
+import { AppTypecheckCommand } from './app/app-typecheck';
import { AppUninstallCommand } from './app/app-uninstall';
import { AuthListCommand } from './auth/auth-list';
import { AuthLoginCommand } from './auth/auth-login';
@@ -60,6 +61,7 @@ export const registerCommands = (program: Command): void => {
// App commands
const devCommand = new AppDevCommand();
+ const typecheckCommand = new AppTypecheckCommand();
const uninstallCommand = new AppUninstallCommand();
const addCommand = new EntityAddCommand();
const logsCommand = new LogicFunctionLogsCommand();
@@ -74,6 +76,15 @@ export const registerCommands = (program: Command): void => {
});
});
+ program
+ .command('app:typecheck [appPath]')
+ .description('Run TypeScript type checking on the application')
+ .action(async (appPath) => {
+ await typecheckCommand.execute({
+ appPath: formatPath(appPath),
+ });
+ });
+
program
.command('app:uninstall [appPath]')
.description('Uninstall application from Twenty')
diff --git a/packages/twenty-sdk/src/cli/commands/app/app-typecheck.ts b/packages/twenty-sdk/src/cli/commands/app/app-typecheck.ts
new file mode 100644
index 0000000000..2216d9f6be
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/commands/app/app-typecheck.ts
@@ -0,0 +1,43 @@
+import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
+import {
+ runTypecheck,
+ type TypecheckError,
+} from '@/cli/utilities/build/common/typecheck-plugin';
+import chalk from 'chalk';
+
+export type AppTypecheckOptions = {
+ appPath?: string;
+};
+
+const formatTypecheckError = (error: TypecheckError): string => {
+ return `${chalk.cyan(error.file)}:${chalk.yellow(String(error.line))}:${chalk.yellow(String(error.column + 1))} - ${chalk.red('error')} ${error.text}`;
+};
+
+export class AppTypecheckCommand {
+ async execute(options: AppTypecheckOptions): Promise {
+ const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
+
+ console.log(chalk.blue('Running type check...'));
+ console.log(chalk.gray(`App path: ${appPath}`));
+ console.log('');
+
+ const errors = await runTypecheck(appPath);
+
+ if (errors.length === 0) {
+ console.log(chalk.green('✓ No type errors found'));
+ process.exit(0);
+ }
+
+ for (const error of errors) {
+ console.log(formatTypecheckError(error));
+ }
+
+ console.log('');
+ console.log(
+ chalk.red(
+ `✗ Found ${errors.length} type error${errors.length === 1 ? '' : 's'}`,
+ ),
+ );
+ process.exit(1);
+ }
+}
diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts
index ef0f3573f7..b309175734 100644
--- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts
+++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts
@@ -356,9 +356,27 @@ export class ApiService {
message: `Successfully synced application: ${manifest.application.displayName}`,
};
} catch (error) {
+ if (axios.isAxiosError(error) && error.response) {
+ const graphqlErrors = error.response.data?.errors;
+
+ if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) {
+ return {
+ success: false,
+ error: graphqlErrors[0]?.message || error.message,
+ };
+ }
+
+ return {
+ success: false,
+ error:
+ error.response.data?.message ||
+ `HTTP ${error.response.status}: ${error.message}`,
+ };
+ }
+
return {
success: false,
- error,
+ error: error instanceof Error ? error.message : error,
};
}
}
diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-object-fields.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-object-fields.ts
index 74fada0dec..67cb83df73 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-object-fields.ts
+++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/get-default-object-fields.ts
@@ -13,7 +13,7 @@ export const getDefaultObjectFields = (
icon: 'Icon123',
isNullable: false,
defaultValue: 'uuid',
- type: FieldMetadataType.UUID,
+ type: FieldMetadataType.UUID as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'id',
@@ -27,7 +27,7 @@ export const getDefaultObjectFields = (
icon: 'IconAbc',
isNullable: true,
defaultValue: null,
- type: FieldMetadataType.TEXT,
+ type: FieldMetadataType.TEXT as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'name',
@@ -41,7 +41,7 @@ export const getDefaultObjectFields = (
icon: 'IconCalendar',
isNullable: false,
defaultValue: 'now',
- type: FieldMetadataType.DATE_TIME,
+ type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'createdAt',
@@ -55,7 +55,7 @@ export const getDefaultObjectFields = (
icon: 'IconCalendarClock',
isNullable: false,
defaultValue: 'now',
- type: FieldMetadataType.DATE_TIME,
+ type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'updatedAt',
@@ -69,7 +69,7 @@ export const getDefaultObjectFields = (
icon: 'IconCalendarClock',
isNullable: true,
defaultValue: null,
- type: FieldMetadataType.DATE_TIME,
+ type: FieldMetadataType.DATE_TIME as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'deletedAt',
@@ -83,7 +83,7 @@ export const getDefaultObjectFields = (
icon: 'IconCreativeCommonsSa',
isNullable: false,
defaultValue: { name: "''", source: "'MANUAL'" },
- type: FieldMetadataType.ACTOR,
+ type: FieldMetadataType.ACTOR as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'createdBy',
@@ -97,7 +97,7 @@ export const getDefaultObjectFields = (
icon: 'IconUserCircle',
isNullable: false,
defaultValue: { name: "''", source: "'MANUAL'" },
- type: FieldMetadataType.ACTOR,
+ type: FieldMetadataType.ACTOR as const,
universalIdentifier: generateDefaultFieldUniversalIdentifier({
objectConfig,
fieldName: 'updatedBy',
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
index a5fe63648c..d6af98545e 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
@@ -40,7 +40,8 @@ export type OrchestratorStateFileStatus =
| 'pending'
| 'building'
| 'uploading'
- | 'success';
+ | 'success'
+ | 'error';
export type OrchestratorStateEntityInfo = {
name: string;
@@ -81,10 +82,11 @@ const FILE_STATUS_TRANSITION_MATRIX: Record<
OrchestratorStateFileStatus,
OrchestratorStateFileStatus[]
> = {
- pending: ['building', 'uploading', 'success'],
- building: ['pending', 'uploading', 'success'],
- uploading: ['pending', 'success'],
- success: ['pending', 'building', 'uploading'],
+ pending: ['building', 'uploading', 'success', 'error'],
+ building: ['pending', 'uploading', 'success', 'error'],
+ uploading: ['pending', 'success', 'error'],
+ success: ['pending', 'building', 'uploading', 'error'],
+ error: ['pending', 'building', 'uploading', 'success'],
};
export class OrchestratorState {
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts
index b9cd4765c4..5bef891ac4 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator.ts
@@ -13,6 +13,7 @@ import {
} from '@/cli/utilities/dev/orchestrator/steps/start-watchers-orchestrator-step';
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { UploadFilesOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/upload-files-orchestrator-step';
+import { serializeError } from '@/cli/utilities/error/serialize-error';
import * as fs from 'fs-extra';
import path from 'path';
import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
@@ -143,10 +144,11 @@ export class DevModeOrchestrator {
await this.runSyncPipeline();
} catch (error) {
this.state.addEvent({
- message: `Sync failed with error ${JSON.stringify(error, null, 2)}`,
+ message: `Sync failed with error: ${serializeError(error)}`,
status: 'error',
});
this.state.updatePipeline({ status: 'error' });
+ this.state.updateAllEntitiesStatus('error');
} finally {
this.state.updatePipeline({ isSyncing: false });
}
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts
index aa86e38e6d..c9e5976714 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step.ts
@@ -7,6 +7,7 @@ import {
type OrchestratorStateStepEvent,
type OrchestratorStateSyncStatus,
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
+import { serializeError } from '@/cli/utilities/error/serialize-error';
import { type Manifest } from 'twenty-shared/application';
export type SyncApplicationOrchestratorStepOutput = {
@@ -73,12 +74,13 @@ export class SyncApplicationOrchestratorStep {
return;
}
- const errorMessage = `Sync failed with error ${JSON.stringify(syncResult.error, null, 2)}`;
+ const errorMessage = `Sync failed with error: ${serializeError(syncResult.error)}`;
events.push({ message: errorMessage, status: 'error' });
step.output = { syncStatus: 'error', error: errorMessage };
step.status = 'error';
this.state.updatePipeline({ status: 'error', error: errorMessage });
+ this.state.updateAllEntitiesStatus('error');
this.state.applyStepEvents(events);
}
}
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-entity-section.tsx b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-entity-section.tsx
index 60fdb32882..56e23cc36c 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-entity-section.tsx
+++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/components/dev-ui-entity-section.tsx
@@ -92,7 +92,11 @@ export const DevUiEntityLegend = (): React.ReactElement => {
{DEV_UI_STATUS_CONFIG.done.icon}
{' '}
- success
+ success{' '}
+
+ {DEV_UI_STATUS_CONFIG.error.icon}
+ {' '}
+ error
);
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
index 9375a54724..906703fbee 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
@@ -49,6 +49,7 @@ export const mapFileStatusToDevUiStatus = (
building: 'in_progress',
uploading: 'uploading',
success: 'done',
+ error: 'error',
};
return mapping[status];
@@ -182,6 +183,7 @@ export const getPipelineRows = (
): DevUiPipelineRow[] => {
const entities = [...state.entities.values()];
+ const hasError = entities.some((entity) => entity.status === 'error');
const isBuilding = entities.some((entity) => entity.status === 'building');
const allUploaded =
entities.length > 0 &&
@@ -189,11 +191,13 @@ export const getPipelineRows = (
(entity) => entity.status === 'uploading' || entity.status === 'success',
);
- const resourcesBuildStatus: OrchestratorStateStepStatus = isBuilding
- ? 'in_progress'
- : allUploaded
- ? 'done'
- : 'idle';
+ const resourcesBuildStatus: OrchestratorStateStepStatus = hasError
+ ? 'error'
+ : isBuilding
+ ? 'in_progress'
+ : allUploaded
+ ? 'done'
+ : 'idle';
return [
{
diff --git a/packages/twenty-sdk/src/cli/utilities/error/serialize-error.ts b/packages/twenty-sdk/src/cli/utilities/error/serialize-error.ts
new file mode 100644
index 0000000000..a15714ead8
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/error/serialize-error.ts
@@ -0,0 +1,52 @@
+import axios from 'axios';
+
+export const serializeError = (error: unknown): string => {
+ if (typeof error === 'string') {
+ return error;
+ }
+
+ if (axios.isAxiosError(error)) {
+ const parts: string[] = [];
+ const status = error.response?.status;
+ const statusText = error.response?.statusText;
+
+ if (status) {
+ parts.push(`HTTP ${status}${statusText ? ` ${statusText}` : ''}`);
+ }
+
+ const graphqlErrors = error.response?.data?.errors;
+
+ if (Array.isArray(graphqlErrors) && graphqlErrors.length > 0) {
+ const messages = graphqlErrors
+ .map(
+ (graphqlError: { message?: string }) =>
+ graphqlError.message ?? 'Unknown GraphQL error',
+ )
+ .join('; ');
+
+ parts.push(messages);
+ } else if (error.response?.data?.message) {
+ parts.push(error.response.data.message);
+ } else if (error.message) {
+ parts.push(error.message);
+ }
+
+ if (error.code) {
+ parts.push(`(${error.code})`);
+ }
+
+ return parts.join(' - ') || 'Unknown Axios error';
+ }
+
+ if (error instanceof Error) {
+ return error.message || error.toString();
+ }
+
+ const stringified = JSON.stringify(error, null, 2);
+
+ if (stringified === '{}' || stringified === undefined) {
+ return String(error);
+ }
+
+ return stringified;
+};
diff --git a/packages/twenty-server/project.json b/packages/twenty-server/project.json
index 0a6fa91bc3..9f64c70b29 100644
--- a/packages/twenty-server/project.json
+++ b/packages/twenty-server/project.json
@@ -248,14 +248,14 @@
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
- "command": "nx ts-node-no-deps -- src/database/clickHouse/migrations/run-migrations.ts"
+ "command": "nx ts-node-no-deps-transpile-only -- src/database/clickHouse/migrations/run-migrations.ts"
}
},
"clickhouse:seed": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-server",
- "command": "nx ts-node-no-deps -- src/database/clickHouse/seeds/run-seeds.ts"
+ "command": "nx ts-node-no-deps-transpile-only -- src/database/clickHouse/seeds/run-seeds.ts"
}
},
"lingui:extract": {