Display a generic fallback component when initial config load fails (#8588)

Fixes: #8487 #5027 

1. Summary
The purpose of these changes is to elevate the dev/user experience when
the initial config load call fails for whatever reason by displaying a
fallback component.

2. Solution
I ended up making more changes than I initially planned. I had to update
the order of the contexts a bit because `GenericErrorFallback` is
dependent on `AppThemeProvider` for styling and `AppThemeProvider` is
dependent on `ObjectMetadataItemsProvider` for
[`useObjectMetadataItem`](ae2f193d68/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts (L22))
hook (`AppThemeProvider` -> `useColorScheme` -> `useUpdateOneRecord` ->
`useObjectMetadataItem`). I had to create a wrapper component for
`AppThemeProvider` and stylize it in a way that it looks responsive on
both mobile and desktop devices. Finally, I had to introduce the
`isErrored` flag to differentiate the loading and error states.

    There are some improvements we can make later - 
    - Display a loading state for the initial config load
    - Implement a refetch logic for the initial config loading failure
    
3. Recording



https://github.com/user-attachments/assets/c2f43573-8006-4118-8e18-8576099d78fd



https://github.com/user-attachments/assets/9c5853d3-539b-4880-aa38-c416c3e13594

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Khuddite
2024-11-22 16:45:23 +08:00
committed by GitHub
parent 04c359a5dc
commit 62df0f0445
12 changed files with 228 additions and 121 deletions

View File

@ -3,7 +3,6 @@ import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { ChangeEvent, useRef } from 'react'; import { ChangeEvent, useRef } from 'react';
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
import { Button } from 'twenty-ui'; import { Button } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@ -90,33 +89,26 @@ export const FileBlock = createReactBlockSpec(
if (isNonEmptyString(block.props.url)) { if (isNonEmptyString(block.props.url)) {
return ( return (
<AppThemeProvider> <StyledFileLine>
<StyledFileLine> <AttachmentIcon
<AttachmentIcon attachmentType={block.props.fileType as AttachmentType}
attachmentType={block.props.fileType as AttachmentType} ></AttachmentIcon>
></AttachmentIcon> <StyledLink href={block.props.url} target="__blank">
<StyledLink href={block.props.url} target="__blank"> {block.props.name}
{block.props.name} </StyledLink>
</StyledLink> </StyledFileLine>
</StyledFileLine>
</AppThemeProvider>
); );
} }
return ( return (
<AppThemeProvider> <StyledUploadFileContainer>
<StyledUploadFileContainer> <StyledFileInput
<StyledFileInput ref={inputFileRef}
ref={inputFileRef} onChange={handleFileChange}
onChange={handleFileChange} type="file"
type="file" />
/> <Button onClick={handleUploadFileClick} title="Upload File"></Button>
<Button </StyledUploadFileContainer>
onClick={handleUploadFileClick}
title="Upload File"
></Button>
</StyledUploadFileContainer>
</AppThemeProvider>
); );
}, },
}, },

View File

@ -13,7 +13,8 @@ import { PrefetchDataProvider } from '@/prefetch/components/PrefetchDataProvider
import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager'; import { DialogManager } from '@/ui/feedback/dialog-manager/components/DialogManager';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider'; import { SnackBarProvider } from '@/ui/feedback/snack-bar-manager/components/SnackBarProvider';
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; import { UserThemeProviderEffect } from '@/ui/theme/components/AppThemeProvider';
import { BaseThemeProvider } from '@/ui/theme/components/BaseThemeProvider';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { UserProvider } from '@/users/components/UserProvider'; import { UserProvider } from '@/users/components/UserProvider';
import { UserProviderEffect } from '@/users/components/UserProviderEffect'; import { UserProviderEffect } from '@/users/components/UserProviderEffect';
@ -27,17 +28,18 @@ export const AppRouterProviders = () => {
return ( return (
<ApolloProvider> <ApolloProvider>
<ClientConfigProviderEffect /> <BaseThemeProvider>
<ClientConfigProvider> <ClientConfigProviderEffect />
<ChromeExtensionSidecarEffect /> <ClientConfigProvider>
<ChromeExtensionSidecarProvider> <ChromeExtensionSidecarEffect />
<UserProviderEffect /> <ChromeExtensionSidecarProvider>
<UserProvider> <UserProviderEffect />
<AuthProvider> <UserProvider>
<ApolloMetadataClientProvider> <AuthProvider>
<ObjectMetadataItemsProvider> <ApolloMetadataClientProvider>
<PrefetchDataProvider> <ObjectMetadataItemsProvider>
<AppThemeProvider> <PrefetchDataProvider>
<UserThemeProviderEffect />
<SnackBarProvider> <SnackBarProvider>
<DialogManagerScope dialogManagerScopeId="dialog-manager"> <DialogManagerScope dialogManagerScopeId="dialog-manager">
<DialogManager> <DialogManager>
@ -50,15 +52,15 @@ export const AppRouterProviders = () => {
</DialogManager> </DialogManager>
</DialogManagerScope> </DialogManagerScope>
</SnackBarProvider> </SnackBarProvider>
</AppThemeProvider> </PrefetchDataProvider>
</PrefetchDataProvider> <PageChangeEffect />
<PageChangeEffect /> </ObjectMetadataItemsProvider>
</ObjectMetadataItemsProvider> </ApolloMetadataClientProvider>
</ApolloMetadataClientProvider> </AuthProvider>
</AuthProvider> </UserProvider>
</UserProvider> </ChromeExtensionSidecarProvider>
</ChromeExtensionSidecarProvider> </ClientConfigProvider>
</ClientConfigProvider> </BaseThemeProvider>
</ApolloProvider> </ApolloProvider>
); );
}; };

View File

@ -17,7 +17,7 @@ import { workspacesState } from '@/auth/states/workspaces';
import { authProvidersState } from '@/client-config/states/authProvidersState'; import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState'; import { billingState } from '@/client-config/states/billingState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState'; import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { supportChatState } from '@/client-config/states/supportChatState'; import { supportChatState } from '@/client-config/states/supportChatState';
@ -229,8 +229,8 @@ export const useAuth = () => {
const captchaProvider = snapshot const captchaProvider = snapshot
.getLoadable(captchaProviderState) .getLoadable(captchaProviderState)
.getValue(); .getValue();
const isClientConfigLoaded = snapshot const clientConfigApiStatus = snapshot
.getLoadable(isClientConfigLoadedState) .getLoadable(clientConfigApiStatusState)
.getValue(); .getValue();
const isCurrentUserLoaded = snapshot const isCurrentUserLoaded = snapshot
.getLoadable(isCurrentUserLoadedState) .getLoadable(isCurrentUserLoadedState)
@ -244,7 +244,7 @@ export const useAuth = () => {
set(supportChatState, supportChat); set(supportChatState, supportChat);
set(isDebugModeState, isDebugMode); set(isDebugModeState, isDebugMode);
set(captchaProviderState, captchaProvider); set(captchaProviderState, captchaProvider);
set(isClientConfigLoadedState, isClientConfigLoaded); set(clientConfigApiStatusState, clientConfigApiStatus);
set(isCurrentUserLoadedState, isCurrentUserLoaded); set(isCurrentUserLoadedState, isCurrentUserLoaded);
return undefined; return undefined;
}); });

View File

@ -1,11 +1,21 @@
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { ClientConfigError } from '@/error-handler/components/ClientConfigError';
export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({ export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
children, children,
}) => { }) => {
const isClientConfigLoaded = useRecoilValue(isClientConfigLoadedState); const { isLoaded, isErrored, error } = useRecoilValue(
clientConfigApiStatusState,
);
return isClientConfigLoaded ? <>{children}</> : <></>; // TODO: Implement a better loading strategy
if (!isLoaded) return null;
return isErrored && error instanceof Error ? (
<ClientConfigError error={error} />
) : (
children
);
}; };

View File

@ -3,8 +3,8 @@ import { authProvidersState } from '@/client-config/states/authProvidersState';
import { billingState } from '@/client-config/states/billingState'; import { billingState } from '@/client-config/states/billingState';
import { captchaProviderState } from '@/client-config/states/captchaProviderState'; import { captchaProviderState } from '@/client-config/states/captchaProviderState';
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState'; import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState'; import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState'; import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState';
@ -27,8 +27,8 @@ export const ClientConfigProviderEffect = () => {
const setSupportChat = useSetRecoilState(supportChatState); const setSupportChat = useSetRecoilState(supportChatState);
const setSentryConfig = useSetRecoilState(sentryConfigState); const setSentryConfig = useSetRecoilState(sentryConfigState);
const [isClientConfigLoaded, setIsClientConfigLoaded] = useRecoilState( const [clientConfigApiStatus, setClientConfigApiStatus] = useRecoilState(
isClientConfigLoadedState, clientConfigApiStatusState,
); );
const setCaptchaProvider = useSetRecoilState(captchaProviderState); const setCaptchaProvider = useSetRecoilState(captchaProviderState);
@ -37,42 +37,64 @@ export const ClientConfigProviderEffect = () => {
const setApiConfig = useSetRecoilState(apiConfigState); const setApiConfig = useSetRecoilState(apiConfigState);
const { data, loading } = useGetClientConfigQuery({ const { data, loading, error } = useGetClientConfigQuery({
skip: isClientConfigLoaded, skip: clientConfigApiStatus.isLoaded,
}); });
useEffect(() => { useEffect(() => {
if (!loading && isDefined(data?.clientConfig)) { if (loading) return;
setIsClientConfigLoaded(true); setClientConfigApiStatus((currentStatus) => ({
setAuthProviders({ ...currentStatus,
google: data?.clientConfig.authProviders.google, isLoaded: true,
microsoft: data?.clientConfig.authProviders.microsoft, }));
password: data?.clientConfig.authProviders.password,
magicLink: false,
sso: data?.clientConfig.authProviders.sso,
});
setIsDebugMode(data?.clientConfig.debugMode);
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled);
setIsSignInPrefilled(data?.clientConfig.signInPrefilled);
setIsSignUpDisabled(data?.clientConfig.signUpDisabled);
setBilling(data?.clientConfig.billing); if (error instanceof Error) {
setSupportChat(data?.clientConfig.support); setClientConfigApiStatus((currentStatus) => ({
...currentStatus,
setSentryConfig({ isErrored: true,
dsn: data?.clientConfig?.sentry?.dsn, error,
release: data?.clientConfig?.sentry?.release, }));
environment: data?.clientConfig?.sentry?.environment, return;
});
setCaptchaProvider({
provider: data?.clientConfig?.captcha?.provider,
siteKey: data?.clientConfig?.captcha?.siteKey,
});
setChromeExtensionId(data?.clientConfig?.chromeExtensionId);
setApiConfig(data?.clientConfig?.api);
} }
if (!isDefined(data?.clientConfig)) {
return;
}
setClientConfigApiStatus((currentStatus) => ({
...currentStatus,
isErrored: false,
error: undefined,
}));
setAuthProviders({
google: data?.clientConfig.authProviders.google,
microsoft: data?.clientConfig.authProviders.microsoft,
password: data?.clientConfig.authProviders.password,
magicLink: false,
sso: data?.clientConfig.authProviders.sso,
});
setIsDebugMode(data?.clientConfig.debugMode);
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled);
setIsSignInPrefilled(data?.clientConfig.signInPrefilled);
setIsSignUpDisabled(data?.clientConfig.signUpDisabled);
setBilling(data?.clientConfig.billing);
setSupportChat(data?.clientConfig.support);
setSentryConfig({
dsn: data?.clientConfig?.sentry?.dsn,
release: data?.clientConfig?.sentry?.release,
environment: data?.clientConfig?.sentry?.environment,
});
setCaptchaProvider({
provider: data?.clientConfig?.captcha?.provider,
siteKey: data?.clientConfig?.captcha?.siteKey,
});
setChromeExtensionId(data?.clientConfig?.chromeExtensionId);
setApiConfig(data?.clientConfig?.api);
}, [ }, [
data, data,
setAuthProviders, setAuthProviders,
@ -83,11 +105,12 @@ export const ClientConfigProviderEffect = () => {
setBilling, setBilling,
setSentryConfig, setSentryConfig,
loading, loading,
setIsClientConfigLoaded, setClientConfigApiStatus,
setCaptchaProvider, setCaptchaProvider,
setChromeExtensionId, setChromeExtensionId,
setApiConfig, setApiConfig,
setIsAnalyticsEnabled, setIsAnalyticsEnabled,
error,
]); ]);
return <></>; return <></>;

View File

@ -0,0 +1,12 @@
import { createState } from 'twenty-ui';
type ClientConfigApiStatus = {
isLoaded: boolean;
isErrored: boolean;
error?: Error;
};
export const clientConfigApiStatusState = createState<ClientConfigApiStatus>({
key: 'clientConfigApiStatus',
defaultValue: { isLoaded: false, isErrored: false, error: undefined },
});

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const isClientConfigLoadedState = createState<boolean>({
key: 'isClientConfigLoadedState',
defaultValue: false,
});

View File

@ -12,10 +12,16 @@ export const AppErrorBoundary = ({ children }: { children: ReactNode }) => {
}); });
}; };
// TODO: Implement a better reset strategy, hard reload for now
const handleReset = () => {
window.location.reload();
};
return ( return (
<ErrorBoundary <ErrorBoundary
FallbackComponent={GenericErrorFallback} FallbackComponent={GenericErrorFallback}
onError={handleError} onError={handleError}
onReset={handleReset}
> >
{children} {children}
</ErrorBoundary> </ErrorBoundary>

View File

@ -0,0 +1,41 @@
import styled from '@emotion/styled';
import { MOBILE_VIEWPORT } from 'twenty-ui';
import { GenericErrorFallback } from './GenericErrorFallback';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.noisy};
box-sizing: border-box;
display: flex;
height: 100dvh;
width: 100%;
padding-top: ${({ theme }) => theme.spacing(3)};
padding-left: ${({ theme }) => theme.spacing(3)};
padding-bottom: 0;
@media (max-width: ${MOBILE_VIEWPORT}px) {
padding-left: 0;
padding-bottom: ${({ theme }) => theme.spacing(3)};
}
`;
type ClientConfigErrorProps = {
error: Error;
};
export const ClientConfigError = ({ error }: ClientConfigErrorProps) => {
// TODO: Implement a better loading strategy
const handleReset = () => {
window.location.reload();
};
return (
<StyledContainer>
<GenericErrorFallback
error={error}
resetErrorBoundary={handleReset}
title="Unable to Reach Back-end"
hidePageHeader
/>
</StyledContainer>
);
};

View File

@ -15,11 +15,16 @@ import {
} from 'twenty-ui'; } from 'twenty-ui';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type GenericErrorFallbackProps = FallbackProps; type GenericErrorFallbackProps = FallbackProps & {
title?: string;
hidePageHeader?: boolean;
};
export const GenericErrorFallback = ({ export const GenericErrorFallback = ({
error, error,
resetErrorBoundary, resetErrorBoundary,
title = 'Sorry, something went wrong',
hidePageHeader = false,
}: GenericErrorFallbackProps) => { }: GenericErrorFallbackProps) => {
const location = useLocation(); const location = useLocation();
@ -33,13 +38,14 @@ export const GenericErrorFallback = ({
return ( return (
<PageContainer> <PageContainer>
<PageHeader /> {!hidePageHeader && <PageHeader />}
<PageBody> <PageBody>
<AnimatedPlaceholderEmptyContainer> <AnimatedPlaceholderEmptyContainer>
<AnimatedPlaceholder type="errorIndex" /> <AnimatedPlaceholder type="errorIndex" />
<AnimatedPlaceholderEmptyTextContainer> <AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle> <AnimatedPlaceholderEmptyTitle>
Servers on a coffee break {title}
</AnimatedPlaceholderEmptyTitle> </AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle> <AnimatedPlaceholderEmptySubTitle>
{error.message} {error.message}
@ -49,7 +55,7 @@ export const GenericErrorFallback = ({
Icon={IconRefresh} Icon={IconRefresh}
title="Reload" title="Reload"
variant={'secondary'} variant={'secondary'}
onClick={() => resetErrorBoundary()} onClick={resetErrorBoundary}
/> />
</AnimatedPlaceholderEmptyContainer> </AnimatedPlaceholderEmptyContainer>
</PageBody> </PageBody>

View File

@ -1,32 +1,17 @@
import { useEffect } from 'react'; import { useContext, useEffect } from 'react';
import { ThemeProvider } from '@emotion/react';
import { THEME_DARK, THEME_LIGHT, ThemeContextProvider } from 'twenty-ui';
import { ThemeSchemeContext } from '@/ui/theme/components/BaseThemeProvider';
import { useSystemColorScheme } from '@/ui/theme/hooks/useSystemColorScheme';
import { useColorScheme } from '../hooks/useColorScheme'; import { useColorScheme } from '../hooks/useColorScheme';
import { useSystemColorScheme } from '../hooks/useSystemColorScheme';
type AppThemeProviderProps = {
children: JSX.Element;
};
export const AppThemeProvider = ({ children }: AppThemeProviderProps) => {
const systemColorScheme = useSystemColorScheme();
export const UserThemeProviderEffect = () => {
const { colorScheme } = useColorScheme(); const { colorScheme } = useColorScheme();
const systemColorScheme = useSystemColorScheme();
const computedColorScheme = const setThemeScheme = useContext(ThemeSchemeContext);
colorScheme === 'System' ? systemColorScheme : colorScheme;
const theme = computedColorScheme === 'Dark' ? THEME_DARK : THEME_LIGHT;
useEffect(() => { useEffect(() => {
document.documentElement.className = setThemeScheme(colorScheme === 'System' ? systemColorScheme : colorScheme);
theme.name === 'dark' ? 'dark' : 'light'; }, [colorScheme, setThemeScheme, systemColorScheme]);
}, [theme]);
return ( return <></>;
<ThemeProvider theme={theme}>
<ThemeContextProvider theme={theme}>{children}</ThemeContextProvider>
</ThemeProvider>
);
}; };

View File

@ -0,0 +1,36 @@
import { ThemeProvider } from '@emotion/react';
import { createContext, useState } from 'react';
import {
ColorScheme,
THEME_DARK,
THEME_LIGHT,
ThemeContextProvider,
} from 'twenty-ui';
import { useSystemColorScheme } from '../hooks/useSystemColorScheme';
type BaseThemeProviderProps = {
children: JSX.Element | JSX.Element[];
};
export const ThemeSchemeContext = createContext<(theme: ColorScheme) => void>(
() => {},
);
export const BaseThemeProvider = ({ children }: BaseThemeProviderProps) => {
const systemColorScheme = useSystemColorScheme();
const [themeScheme, setThemeScheme] = useState(systemColorScheme);
document.documentElement.className =
themeScheme === 'Dark' ? 'dark' : 'light';
const theme = themeScheme === 'Dark' ? THEME_DARK : THEME_LIGHT;
return (
<ThemeSchemeContext.Provider value={setThemeScheme}>
<ThemeProvider theme={theme}>
<ThemeContextProvider theme={theme}>{children}</ThemeContextProvider>
</ThemeProvider>
</ThemeSchemeContext.Provider>
);
};