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`.
This commit is contained in:
Charles Bochet
2026-02-23 19:57:02 +01:00
committed by GitHub
parent ccddd105d8
commit 0d4fe4575b
38 changed files with 675 additions and 450 deletions
@@ -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 = () => {
<ApolloProvider>
<BaseThemeProvider>
<ClientConfigProviderEffect />
<MetadataProviderEffect />
<EagerMetadataLoadEffect />
<MetadataProviderInitialEffects />
<LazyMetadataLoadEffect />
<IsAppMetadataReadyEffect />
<WorkspaceProviderEffect />
<ClientConfigProvider>
<CaptchaProvider>
<ChromeExtensionSidecarEffect />
<ChromeExtensionSidecarProvider>
<UserProvider>
<MetadataGater>
<AuthProvider>
<ApolloCoreProvider>
<SSEProvider>
<ObjectMetadataItemsProvider>
<PreComputedChipGeneratorsProvider>
<PrefetchDataProvider>
<UserThemeProviderEffect />
<SnackBarProvider>
@@ -81,11 +81,11 @@ export const AppRouterProviders = () => {
<SupportChatEffect />
</PrefetchDataProvider>
<PageChangeEffect />
</ObjectMetadataItemsProvider>
</PreComputedChipGeneratorsProvider>
</SSEProvider>
</ApolloCoreProvider>
</AuthProvider>
</UserProvider>
</MetadataGater>
</ChromeExtensionSidecarProvider>
</CaptchaProvider>
</ClientConfigProvider>
@@ -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 };
};
@@ -1,12 +0,0 @@
import { metadataStoreState } from '@/app/states/metadataStoreState';
import { createSelectorV2 } from '@/ui/utilities/state/jotai/utils/createSelectorV2';
export const isAppLoadingState = createSelectorV2<boolean>({
key: 'isAppLoadingState',
get: ({ get }) => {
const objectsEntry = get(metadataStoreState, 'objects');
const viewsEntry = get(metadataStoreState, 'views');
return objectsEntry.status !== 'loaded' || viewsEntry.status !== 'loaded';
},
});
@@ -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,
],
);
@@ -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';
@@ -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 ? (
<UserOrMetadataLoader />
) : (
const shouldShowLoader =
(!isAppMetadataReady && isLoggedIn && !isOnExcludedPath) ||
objectMetadataItems.length === 0;
if (shouldShowLoader) {
return <UserOrMetadataLoader />;
}
return (
<UserContext.Provider
value={{
dateFormat,
@@ -0,0 +1,54 @@
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { useFamilyRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useFamilyRecoilValueV2';
import { useRecoilValueV2 } from '@/ui/utilities/state/jotai/hooks/useRecoilValueV2';
import { useSetRecoilStateV2 } from '@/ui/utilities/state/jotai/hooks/useSetRecoilStateV2';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
export const IsAppMetadataReadyEffect = () => {
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;
};
@@ -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 = () => (
<>
<UserMetadataProviderInitialEffect />
<ObjectMetadataProviderInitialEffect />
<ViewMetadataProviderInitialEffect />
</>
);
@@ -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;
};
@@ -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;
@@ -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;
};
@@ -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<typeof createStore>;
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 };
};
@@ -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 };
};
@@ -1,6 +1,6 @@
import { createStateV2 } from '@/ui/utilities/state/jotai/utils/createStateV2';
export const shouldAppBeLoadingState = createStateV2<boolean>({
key: 'shouldAppBeLoadingState',
export const isAppMetadataReadyState = createStateV2<boolean>({
key: 'isAppMetadataReadyState',
defaultValue: false,
});
@@ -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[];
@@ -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 ? (
<PreComputedChipGeneratorsProvider>
{children}
</PreComputedChipGeneratorsProvider>
) : (
<UserOrMetadataLoader />
)}
</>
);
};
@@ -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<FieldMetadata>[] = useMemo(() => {
const activeFieldMetadataItems =
objectMetadataItem.readableFields.filter(
(field) =>
field.isActive &&
(!isHiddenSystemField(field) ||
field.id === objectMetadataItem.labelIdentifierFieldMetadataId),
);
const columnDefinitions: ColumnDefinition<FieldMetadata>[] =
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,
@@ -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<typeof DashboardWidgetPlaceholder> = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
jotaiStore.set(shouldAppBeLoadingState.atom, false);
jotaiStore.set(isAppMetadataReadyState.atom, true);
snapshot.set(
pageLayoutPersistedComponentState.atomFamily({
instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID,
@@ -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<Story, typeof WidgetRenderer> = {
objectMetadataItemsState.atom,
generatedMockObjectMetadataItems,
);
jotaiStore.set(shouldAppBeLoadingState.atom, false);
jotaiStore.set(isAppMetadataReadyState.atom, true);
if (state === 'hover') {
snapshot.set(
@@ -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,
@@ -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);
};
@@ -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)
@@ -45,6 +45,7 @@ export const createStateV2 = <ValueType>({
useCookieStorage.cookieKey,
defaultValue,
storage,
{ getOnInit: true },
) as StateAtom<ValueType>;
} else if (useLocalStorage) {
baseAtom = atomWithStorage<ValueType>(
@@ -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;
};
@@ -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;
};
@@ -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 = () => {
<ApolloStorybookDevLogEffect />
<ClientConfigProviderEffect />
<ClientConfigProvider>
<MetadataProviderEffect />
<MetadataProviderInitialEffects />
<IsAppMetadataReadyEffect />
<WorkspaceProviderEffect />
<UserProvider>
<MetadataGater>
<ApolloCoreClientMockedProvider>
<ObjectMetadataItemsLoadEffect />
<ObjectMetadataItemsProvider>
<PreComputedChipGeneratorsProvider>
<FullHeightStorybookLayout>
<HelmetProvider>
<IconsProvider>
@@ -105,10 +105,10 @@ const Providers = () => {
</IconsProvider>
</HelmetProvider>
</FullHeightStorybookLayout>
</ObjectMetadataItemsProvider>
</PreComputedChipGeneratorsProvider>
<MainContextStoreProvider />
</ApolloCoreClientMockedProvider>
</UserProvider>
</MetadataGater>
</ClientConfigProvider>
</I18nProvider>
</ApolloProvider>
+6
View File
@@ -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
@@ -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')
@@ -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<void> {
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);
}
}
@@ -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,
};
}
}
@@ -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',
@@ -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 {
@@ -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 });
}
@@ -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);
}
}
@@ -92,7 +92,11 @@ export const DevUiEntityLegend = (): React.ReactElement => {
<Text color={DEV_UI_STATUS_CONFIG.done.color}>
{DEV_UI_STATUS_CONFIG.done.icon}
</Text>{' '}
success
success{' '}
<Text color={DEV_UI_STATUS_CONFIG.error.color}>
{DEV_UI_STATUS_CONFIG.error.icon}
</Text>{' '}
error
</Text>
</Box>
);
@@ -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 [
{
@@ -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;
};
+2 -2
View File
@@ -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": {