App version mismatch handling between frontend and backend (#13368)
https://github.com/user-attachments/assets/d153f177-4d70-4ec6-8693-15413e550938
This commit is contained in:
@ -4,6 +4,7 @@ import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { SnackBarComponentInstanceContextProvider } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarComponentInstanceContextProvider';
|
||||
import { useApolloFactory } from '../useApolloFactory';
|
||||
|
||||
enableFetchMocks();
|
||||
@ -25,7 +26,9 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
initialEntries={['/welcome', '/verify', '/opportunities']}
|
||||
initialIndex={2}
|
||||
>
|
||||
{children}
|
||||
<SnackBarComponentInstanceContextProvider snackBarComponentInstanceId="test-instance-id">
|
||||
{children}
|
||||
</SnackBarComponentInstanceContextProvider>
|
||||
</MemoryRouter>
|
||||
</RecoilRoot>
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
@ -13,7 +13,9 @@ import { useUpdateEffect } from '~/hooks/useUpdateEffect';
|
||||
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
||||
|
||||
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
|
||||
import { appVersionState } from '@/client-config/states/appVersionState';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ApolloFactory, Options } from '../services/apollo.factory';
|
||||
|
||||
@ -26,6 +28,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
|
||||
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
const appVersion = useRecoilValue(appVersionState);
|
||||
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
|
||||
currentWorkspaceMemberState,
|
||||
);
|
||||
@ -35,6 +38,8 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
|
||||
const setPreviousUrl = useSetRecoilState(previousUrlState);
|
||||
const location = useLocation();
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const apolloClient = useMemo(() => {
|
||||
apolloRef.current = new ApolloFactory({
|
||||
uri: `${REACT_APP_SERVER_BASE_URL}/graphql`,
|
||||
@ -54,6 +59,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
|
||||
connectToDevTools: process.env.IS_DEBUG_MODE === 'true',
|
||||
currentWorkspaceMember: currentWorkspaceMember,
|
||||
currentWorkspace: currentWorkspace,
|
||||
appVersion,
|
||||
onTokenPairChange: (tokenPair) => {
|
||||
setTokenPair(tokenPair);
|
||||
},
|
||||
@ -73,6 +79,14 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
|
||||
navigate(AppPath.SignInUp);
|
||||
}
|
||||
},
|
||||
onAppVersionMismatch: (message) => {
|
||||
enqueueErrorSnackBar({
|
||||
message,
|
||||
options: {
|
||||
dedupeKey: 'app-version-mismatch',
|
||||
},
|
||||
});
|
||||
},
|
||||
extraLinks: [],
|
||||
isDebugMode: process.env.IS_DEBUG_MODE === 'true',
|
||||
// Override options
|
||||
@ -87,6 +101,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
|
||||
setCurrentWorkspaceMember,
|
||||
setCurrentWorkspace,
|
||||
setPreviousUrl,
|
||||
enqueueErrorSnackBar,
|
||||
]);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
@ -101,5 +116,11 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
|
||||
}
|
||||
}, [currentWorkspace]);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
if (isDefined(apolloRef.current)) {
|
||||
apolloRef.current.updateAppVersion(appVersion);
|
||||
}
|
||||
}, [appVersion]);
|
||||
|
||||
return apolloClient;
|
||||
};
|
||||
|
||||
@ -64,6 +64,7 @@ const createMockOptions = (): Options<any> => ({
|
||||
isDebugMode: true,
|
||||
onError: mockOnError,
|
||||
onNetworkError: mockOnNetworkError,
|
||||
appVersion: '1.0.0',
|
||||
});
|
||||
|
||||
const makeRequest = async () => {
|
||||
|
||||
@ -23,6 +23,7 @@ import { logDebug } from '~/utils/logDebug';
|
||||
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { i18n } from '@lingui/core';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
DefinitionNode,
|
||||
DirectiveNode,
|
||||
@ -45,16 +46,19 @@ export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
|
||||
onNetworkError?: (err: Error | ServerParseError | ServerError) => void;
|
||||
onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
|
||||
onUnauthenticatedError?: () => void;
|
||||
onAppVersionMismatch?: (message: string) => void;
|
||||
currentWorkspaceMember: CurrentWorkspaceMember | null;
|
||||
currentWorkspace: CurrentWorkspace | null;
|
||||
extraLinks?: ApolloLink[];
|
||||
isDebugMode?: boolean;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
private client: ApolloClient<TCacheShape>;
|
||||
private currentWorkspaceMember: CurrentWorkspaceMember | null = null;
|
||||
private currentWorkspace: CurrentWorkspace | null = null;
|
||||
private appVersion?: string;
|
||||
|
||||
constructor(opts: Options<TCacheShape>) {
|
||||
const {
|
||||
@ -63,15 +67,18 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
onNetworkError,
|
||||
onTokenPairChange,
|
||||
onUnauthenticatedError,
|
||||
onAppVersionMismatch,
|
||||
currentWorkspaceMember,
|
||||
currentWorkspace,
|
||||
extraLinks,
|
||||
isDebugMode,
|
||||
appVersion,
|
||||
...options
|
||||
} = opts;
|
||||
|
||||
this.currentWorkspaceMember = currentWorkspaceMember;
|
||||
this.currentWorkspace = currentWorkspace;
|
||||
this.appVersion = appVersion;
|
||||
|
||||
const buildApolloLink = (): ApolloLink => {
|
||||
const uploadLink = createUploadLink({
|
||||
@ -111,6 +118,7 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
...(this.currentWorkspace?.metadataVersion && {
|
||||
'X-Schema-Version': `${this.currentWorkspace.metadataVersion}`,
|
||||
}),
|
||||
...(this.appVersion && { 'X-App-Version': this.appVersion }),
|
||||
},
|
||||
};
|
||||
});
|
||||
@ -158,6 +166,13 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
}
|
||||
|
||||
switch (graphQLError?.extensions?.code) {
|
||||
case 'APP_VERSION_MISMATCH': {
|
||||
onAppVersionMismatch?.(
|
||||
(graphQLError.extensions?.userFriendlyMessage as string) ||
|
||||
t`Your app version is out of date. Please refresh the page.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case 'UNAUTHENTICATED': {
|
||||
return handleTokenRenewal(operation, forward);
|
||||
}
|
||||
@ -289,6 +304,10 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
|
||||
this.currentWorkspace = workspace;
|
||||
}
|
||||
|
||||
updateAppVersion(appVersion?: string) {
|
||||
this.appVersion = appVersion;
|
||||
}
|
||||
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
|
||||
import { aiModelsState } from '@/client-config/states/aiModelsState';
|
||||
import { apiConfigState } from '@/client-config/states/apiConfigState';
|
||||
import { appVersionState } from '@/client-config/states/appVersionState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
@ -97,6 +98,8 @@ export const ClientConfigProviderEffect = () => {
|
||||
isImapSmtpCaldavEnabledState,
|
||||
);
|
||||
|
||||
const setAppVersion = useSetRecoilState(appVersionState);
|
||||
|
||||
const { data, loading, error, fetchClientConfig } = useClientConfig();
|
||||
|
||||
useEffect(() => {
|
||||
@ -133,7 +136,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
isErrored: false,
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
setAppVersion(data.clientConfig.appVersion);
|
||||
setAuthProviders({
|
||||
google: data?.clientConfig.authProviders.google,
|
||||
microsoft: data?.clientConfig.authProviders.microsoft,
|
||||
@ -217,6 +220,7 @@ export const ClientConfigProviderEffect = () => {
|
||||
setIsConfigVariablesInDbEnabled,
|
||||
setCalendarBookingPageId,
|
||||
setIsImapSmtpCaldavEnabled,
|
||||
setAppVersion,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const appVersionState = createState<string | undefined>({
|
||||
key: 'appVersion',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export type ClientConfig = {
|
||||
appVersion?: string;
|
||||
aiModels: Array<ClientAiModelConfig>;
|
||||
analyticsEnabled: boolean;
|
||||
api: ApiConfig;
|
||||
|
||||
Reference in New Issue
Block a user