From 0d4fe4575bc62340fd264e9a801803ef410a15d2 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Mon, 23 Feb 2026 19:57:02 +0100 Subject: [PATCH] Various SDK improvements (#18115) ## Summary - **Refactor frontend metadata loading architecture**: Split the monolithic `EagerMetadataLoadEffect` into focused provider effects (`UserMetadataProviderEffect`, `ObjectMetadataProviderEffect`, `ViewMetadataProviderEffect`) orchestrated by `MetadataProviderEffects`. Replaced `UserProvider` + `ObjectMetadataItemsProvider` with a single `MetadataGater` that gates rendering on `isAppMetadataReadyState`. The metadata store now validates view-object consistency before promoting views, and `updateDraft` skips no-op updates via deep equality checks. - **SDK CLI improvements**: Added `app:typecheck` command, improved error handling in API sync (extracts GraphQL error messages), added `serializeError` utility for human-readable error output, added `error` file status to dev mode orchestrator with UI support, and fixed ClickHouse migration/seed commands to use `transpile-only`. --- .../app/components/AppRouterProviders.tsx | 20 +-- .../src/modules/app/hooks/useMetadataStore.ts | 84 ------------ .../modules/app/states/isAppLoadingState.ts | 12 -- .../src/modules/auth/hooks/useAuth.ts | 40 +----- .../components/MainContextStoreProvider.tsx | 2 +- .../components/MetadataGater.tsx} | 30 +++-- .../IsAppMetadataReadyEffect.tsx | 54 ++++++++ .../MetadataProviderInitialEffects.tsx | 11 ++ .../ObjectMetadataProviderInitialEffect.tsx} | 49 ++++--- .../UserMetadataProviderInitialEffect.tsx} | 63 +++++---- .../ViewMetadataProviderInitialEffect.tsx | 87 +++++++++++++ .../metadata-store/hooks/useMetadataStore.ts | 111 ++++++++++++++++ .../hooks/useReloadWorkspaceMetadata.ts | 50 ++++++++ .../states/isAppMetadataReadyState.ts} | 4 +- .../states/metadataStoreState.ts | 7 + .../ObjectMetadataItemsProvider.tsx | 30 ----- .../useColumnDefinitionsFromObjectMetadata.ts | 27 ++-- .../DashboardWidgetPlaceholder.stories.tsx | 4 +- .../__stories__/WidgetRenderer.stories.tsx | 30 ++--- .../__stories__/FieldWidget.stories.tsx | 36 +++--- .../admin-panel/hooks/useImpersonationAuth.ts | 4 - .../utilities/state/jotai/MIGRATION_PLAN.md | 1 - .../state/jotai/utils/createStateV2.ts | 1 + .../components/EagerMetadataLoadEffect.tsx | 121 ------------------ .../components/LazyMetadataLoadEffect.tsx | 37 +++--- .../src/testing/decorators/PageDecorator.tsx | 20 +-- packages/twenty-sdk/README.md | 6 + .../src/cli/commands/app-command.ts | 11 ++ .../src/cli/commands/app/app-typecheck.ts | 43 +++++++ .../src/cli/utilities/api/api-service.ts | 20 ++- .../utils/get-default-object-fields.ts | 14 +- .../dev-mode-orchestrator-state.ts | 12 +- .../dev/orchestrator/dev-mode-orchestrator.ts | 4 +- .../sync-application-orchestrator-step.ts | 4 +- .../ui/components/dev-ui-entity-section.tsx | 6 +- .../cli/utilities/dev/ui/dev-ui-constants.ts | 14 +- .../cli/utilities/error/serialize-error.ts | 52 ++++++++ packages/twenty-server/project.json | 4 +- 38 files changed, 675 insertions(+), 450 deletions(-) delete mode 100644 packages/twenty-front/src/modules/app/hooks/useMetadataStore.ts delete mode 100644 packages/twenty-front/src/modules/app/states/isAppLoadingState.ts rename packages/twenty-front/src/modules/{users/components/UserProvider.tsx => metadata-store/components/MetadataGater.tsx} (53%) create mode 100644 packages/twenty-front/src/modules/metadata-store/effect-components/IsAppMetadataReadyEffect.tsx create mode 100644 packages/twenty-front/src/modules/metadata-store/effect-components/MetadataProviderInitialEffects.tsx rename packages/twenty-front/src/modules/{object-metadata/components/ObjectMetadataItemsLoadEffect.tsx => metadata-store/effect-components/ObjectMetadataProviderInitialEffect.tsx} (56%) rename packages/twenty-front/src/modules/{users/components/MetadataProviderEffect.tsx => metadata-store/effect-components/UserMetadataProviderInitialEffect.tsx} (89%) create mode 100644 packages/twenty-front/src/modules/metadata-store/effect-components/ViewMetadataProviderInitialEffect.tsx create mode 100644 packages/twenty-front/src/modules/metadata-store/hooks/useMetadataStore.ts create mode 100644 packages/twenty-front/src/modules/metadata-store/hooks/useReloadWorkspaceMetadata.ts rename packages/twenty-front/src/modules/{object-metadata/states/shouldAppBeLoadingState.ts => metadata-store/states/isAppMetadataReadyState.ts} (52%) rename packages/twenty-front/src/modules/{app => metadata-store}/states/metadataStoreState.ts (84%) delete mode 100644 packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsProvider.tsx delete mode 100644 packages/twenty-front/src/modules/users/components/EagerMetadataLoadEffect.tsx create mode 100644 packages/twenty-sdk/src/cli/commands/app/app-typecheck.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/error/serialize-error.ts 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": {