Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,14 @@
import { ApolloProvider as ApolloProviderBase } from '@apollo/client';
import { useApolloFactory } from '@/apollo/hooks/useApolloFactory';
export const ApolloProvider = ({ children }: React.PropsWithChildren) => {
const apolloClient = useApolloFactory();
// This will attach the right apollo client to Apollo Dev Tools
window.__APOLLO_CLIENT__ = apolloClient;
return (
<ApolloProviderBase client={apolloClient}>{children}</ApolloProviderBase>
);
};

View File

@ -0,0 +1,65 @@
import { useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { useRecoilState } from 'recoil';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { AppPath } from '@/types/AppPath';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { useUpdateEffect } from '~/hooks/useUpdateEffect';
import { ApolloFactory } from '../services/apollo.factory';
export const useApolloFactory = () => {
// eslint-disable-next-line twenty/no-state-useref
const apolloRef = useRef<ApolloFactory<NormalizedCacheObject> | null>(null);
const [isDebugMode] = useRecoilState(isDebugModeState);
const navigate = useNavigate();
const isMatchingLocation = useIsMatchingLocation();
const [tokenPair, setTokenPair] = useRecoilState(tokenPairState);
const apolloClient = useMemo(() => {
apolloRef.current = new ApolloFactory({
uri: `${REACT_APP_SERVER_BASE_URL}/graphql`,
cache: new InMemoryCache(),
defaultOptions: {
query: {
fetchPolicy: 'cache-first',
},
},
connectToDevTools: isDebugMode,
// We don't want to re-create the client on token change or it will cause infinite loop
initialTokenPair: tokenPair,
onTokenPairChange: (tokenPair) => {
setTokenPair(tokenPair);
},
onUnauthenticatedError: () => {
setTokenPair(null);
if (
!isMatchingLocation(AppPath.Verify) &&
!isMatchingLocation(AppPath.SignIn) &&
!isMatchingLocation(AppPath.SignUp) &&
!isMatchingLocation(AppPath.Invite)
) {
navigate(AppPath.SignIn);
}
},
extraLinks: [],
isDebugMode,
});
return apolloRef.current.getClient();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setTokenPair, isDebugMode]);
useUpdateEffect(() => {
if (apolloRef.current) {
apolloRef.current.updateTokenPair(tokenPair);
}
}, [tokenPair]);
return apolloClient;
};

View File

@ -0,0 +1,156 @@
import {
ApolloCache,
DocumentNode,
OperationVariables,
useApolloClient,
} from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import {
EMPTY_QUERY,
useObjectMetadataItem,
} from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { optimisticEffectState } from '../states/optimisticEffectState';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition';
export const useOptimisticEffect = ({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const apolloClient = useApolloClient();
const { findManyRecordsQuery } = useObjectMetadataItem({
objectNameSingular,
});
const registerOptimisticEffect = useRecoilCallback(
({ snapshot, set }) =>
<T>({
variables,
definition,
}: {
variables: OperationVariables;
definition: OptimisticEffectDefinition;
}) => {
if (findManyRecordsQuery === EMPTY_QUERY) {
throw new Error(
`Trying to register an optimistic effect for unknown object ${objectNameSingular}`,
);
}
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
const optimisticEffectWriter = ({
cache,
newData,
deletedRecordIds,
query,
variables,
objectMetadataItem,
}: {
cache: ApolloCache<unknown>;
newData: unknown;
deletedRecordIds?: string[];
variables: OperationVariables;
query: DocumentNode;
isUsingFlexibleBackend?: boolean;
objectMetadataItem?: ObjectMetadataItem;
}) => {
if (objectMetadataItem) {
const existingData = cache.readQuery({
query: findManyRecordsQuery,
variables,
});
if (!existingData) {
return;
}
cache.writeQuery({
query: findManyRecordsQuery,
variables,
data: {
[objectMetadataItem.namePlural]: definition.resolver({
currentData: (existingData as any)?.[
objectMetadataItem.namePlural
],
newData,
deletedRecordIds,
variables,
}),
},
});
return;
}
const existingData = cache.readQuery({
query,
variables,
});
if (!existingData) {
return;
}
};
const optimisticEffect = {
key: definition.key,
variables,
typename: definition.typename,
query: definition.query,
writer: optimisticEffectWriter,
objectMetadataItem: definition.objectMetadataItem,
isUsingFlexibleBackend: definition.isUsingFlexibleBackend,
} satisfies OptimisticEffect<T>;
set(optimisticEffectState, {
...optimisticEffects,
[definition.key]: optimisticEffect,
});
},
);
const triggerOptimisticEffects = useRecoilCallback(
({ snapshot }) =>
(typename: string, newData: unknown, deletedRecordIds?: string[]) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
for (const optimisticEffect of Object.values(optimisticEffects)) {
// We need to update the typename when createObject type differs from listObject types
// It is the case for apiKey, where the creation route returns an ApiKeyToken type
const formattedNewData = isNonEmptyArray(newData)
? newData.map((data: any) => {
return { ...data, __typename: typename };
})
: newData;
if (optimisticEffect.typename === typename) {
optimisticEffect.writer({
cache: apolloClient.cache,
query: optimisticEffect.query ?? ({} as DocumentNode),
newData: formattedNewData,
deletedRecordIds,
variables: optimisticEffect.variables,
isUsingFlexibleBackend: optimisticEffect.isUsingFlexibleBackend,
objectMetadataItem: optimisticEffect.objectMetadataItem,
});
}
}
},
[apolloClient.cache],
);
return {
registerOptimisticEffect,
triggerOptimisticEffects,
};
};

View File

@ -0,0 +1,24 @@
import { useApolloClient } from '@apollo/client';
export const useOptimisticEvict = () => {
const cache = useApolloClient().cache;
const performOptimisticEvict = (
typename: string,
fieldName: string,
fieldValue: string,
) => {
const serializedCache = cache.extract();
Object.values(serializedCache)
.filter((item) => item.__typename === typename)
.forEach((item) => {
if (item[fieldName] === fieldValue) {
cache.evict({ id: cache.identify(item) });
}
});
};
return {
performOptimisticEvict,
};
};

View File

@ -0,0 +1,10 @@
import { atom } from 'recoil';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
export const optimisticEffectState = atom<
Record<string, OptimisticEffect<unknown>>
>({
key: 'optimisticEffectState',
default: {},
});

View File

@ -0,0 +1,14 @@
import { DocumentNode } from 'graphql';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OptimisticEffectResolver } from './OptimisticEffectResolver';
export type OptimisticEffectDefinition = {
key: string;
query?: DocumentNode;
typename: string;
resolver: OptimisticEffectResolver;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
};

View File

@ -0,0 +1,13 @@
import { OperationVariables } from '@apollo/client';
export type OptimisticEffectResolver = ({
currentData,
newData,
deletedRecordIds,
variables,
}: {
currentData: any; //TODO: Change when decommissioning v1
newData: any; //TODO: Change when decommissioning v1
deletedRecordIds?: string[];
variables: OperationVariables;
}) => void;

View File

@ -0,0 +1,28 @@
import { ApolloCache, DocumentNode, OperationVariables } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
type OptimisticEffectWriter<T> = ({
cache,
newData,
variables,
query,
}: {
cache: ApolloCache<T>;
query: DocumentNode;
newData: T;
deletedRecordIds?: string[];
variables: OperationVariables;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
}) => void;
export type OptimisticEffect<T> = {
key: string;
query?: DocumentNode;
typename: string;
variables: OperationVariables;
writer: OptimisticEffectWriter<T>;
objectMetadataItem?: ObjectMetadataItem;
isUsingFlexibleBackend?: boolean;
};

View File

@ -0,0 +1,159 @@
import {
ApolloClient,
ApolloClientOptions,
ApolloLink,
fromPromise,
ServerError,
ServerParseError,
} from '@apollo/client';
import { GraphQLErrors } from '@apollo/client/errors';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { createUploadLink } from 'apollo-upload-client';
import { renewToken } from '@/auth/services/AuthService';
import { AuthTokenPair } from '~/generated/graphql';
import { assertNotNull } from '~/utils/assert';
import { logDebug } from '~/utils/logDebug';
import { ApolloManager } from '../types/apolloManager.interface';
import { loggerLink } from '../utils';
const logger = loggerLink(() => 'Twenty');
export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
onError?: (err: GraphQLErrors | undefined) => void;
onNetworkError?: (err: Error | ServerParseError | ServerError) => void;
onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
onUnauthenticatedError?: () => void;
initialTokenPair: AuthTokenPair | null;
extraLinks?: ApolloLink[];
isDebugMode?: boolean;
}
export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
private client: ApolloClient<TCacheShape>;
private tokenPair: AuthTokenPair | null = null;
constructor(opts: Options<TCacheShape>) {
const {
uri,
onError: onErrorCb,
onNetworkError,
onTokenPairChange,
onUnauthenticatedError,
initialTokenPair,
extraLinks,
isDebugMode,
...options
} = opts;
this.tokenPair = initialTokenPair;
const buildApolloLink = (): ApolloLink => {
const httpLink = createUploadLink({
uri,
});
const authLink = setContext(async (_, { headers }) => {
return {
headers: {
...headers,
authorization: this.tokenPair?.accessToken.token
? `Bearer ${this.tokenPair?.accessToken.token}`
: '',
},
};
});
const retryLink = new RetryLink({
delay: {
initial: 3000,
},
attempts: {
max: 2,
retryIf: (error) => !!error,
},
});
const errorLink = onError(
({ graphQLErrors, networkError, forward, operation }) => {
if (graphQLErrors) {
onErrorCb?.(graphQLErrors);
for (const graphQLError of graphQLErrors) {
if (graphQLError.message === 'Unauthorized') {
return fromPromise(
renewToken(uri, this.tokenPair)
.then((tokens) => {
onTokenPairChange?.(tokens);
})
.catch(() => {
onUnauthenticatedError?.();
}),
).flatMap(() => forward(operation));
}
switch (graphQLError?.extensions?.code) {
case 'UNAUTHENTICATED': {
return fromPromise(
renewToken(uri, this.tokenPair)
.then((tokens) => {
onTokenPairChange?.(tokens);
})
.catch(() => {
onUnauthenticatedError?.();
}),
).flatMap(() => forward(operation));
}
default:
if (isDebugMode) {
logDebug(
`[GraphQL error]: Message: ${
graphQLError.message
}, Location: ${
graphQLError.locations
? JSON.stringify(graphQLError.locations)
: graphQLError.locations
}, Path: ${graphQLError.path}`,
);
}
}
}
}
if (networkError) {
if (isDebugMode) {
logDebug(`[Network error]: ${networkError}`);
}
onNetworkError?.(networkError);
}
},
);
return ApolloLink.from(
[
errorLink,
authLink,
...(extraLinks || []),
isDebugMode ? logger : null,
retryLink,
httpLink,
].filter(assertNotNull),
);
};
this.client = new ApolloClient({
...options,
link: buildApolloLink(),
});
}
updateTokenPair(tokenPair: AuthTokenPair | null) {
this.tokenPair = tokenPair;
}
getClient() {
return this.client;
}
}

View File

@ -0,0 +1,8 @@
import { ApolloClient } from '@apollo/client';
import { AuthTokenPair } from '~/generated/graphql';
export interface ApolloManager<TCacheShape> {
getClient(): ApolloClient<TCacheShape>;
updateTokenPair(tokenPair: AuthTokenPair | null): void;
}

View File

@ -0,0 +1,6 @@
export enum OperationType {
Query = 'query',
Mutation = 'mutation',
Subscription = 'subscription',
Error = 'error',
}

View File

@ -0,0 +1,50 @@
import { OperationType } from '../types/operation-type';
const operationTypeColors = {
// eslint-disable-next-line twenty/no-hardcoded-colors
query: '#03A9F4',
// eslint-disable-next-line twenty/no-hardcoded-colors
mutation: '#61A600',
// eslint-disable-next-line twenty/no-hardcoded-colors
subscription: '#61A600',
// eslint-disable-next-line twenty/no-hardcoded-colors
error: '#F51818',
// eslint-disable-next-line twenty/no-hardcoded-colors
default: '#61A600',
};
const getOperationColor = (operationType: OperationType) => {
return operationTypeColors[operationType] ?? operationTypeColors.default;
};
const formatTitle = (
operationType: OperationType,
schemaName: string,
queryName: string,
time: string | number,
) => {
const headerCss = [
'color: gray; font-weight: lighter', // title
`color: ${getOperationColor(operationType)}; font-weight: bold;`, // operationType
'color: gray; font-weight: lighter;', // schemaName
'color: black; font-weight: bold;', // queryName
];
const parts = [
'%c apollo',
`%c${operationType}`,
`%c${schemaName}::%c${queryName}`,
];
if (operationType !== OperationType.Subscription) {
parts.push(`%c(in ${time} ms)`);
headerCss.push('color: gray; font-weight: lighter;'); // time
} else {
parts.push(`%c(@ ${time})`);
headerCss.push('color: gray; font-weight: lighter;'); // time
}
return [parts.join(' '), ...headerCss];
};
export default formatTitle;

View File

@ -0,0 +1,105 @@
import { ApolloLink, gql, Operation } from '@apollo/client';
import { logDebug } from '~/utils/logDebug';
import { logError } from '~/utils/logError';
import formatTitle from './format-title';
const getGroup = (collapsed: boolean) =>
collapsed
? console.groupCollapsed.bind(console)
: console.group.bind(console);
const parseQuery = (queryString: string) => {
const queryObj = gql`
${queryString}
`;
const { name } = queryObj.definitions[0] as any;
return [name ? name.value : 'Generic', queryString.trim()];
};
export const loggerLink = (getSchemaName: (operation: Operation) => string) =>
new ApolloLink((operation, forward) => {
const schemaName = getSchemaName(operation);
operation.setContext({ start: Date.now() });
const { variables } = operation;
const operationType = (operation.query.definitions[0] as any).operation;
const headers = operation.getContext().headers;
const [queryName, query] = parseQuery(operation.query.loc!.source.body);
if (operationType === 'subscription') {
const date = new Date().toLocaleTimeString();
const titleArgs = formatTitle(operationType, schemaName, queryName, date);
console.groupCollapsed(...titleArgs);
if (variables && Object.keys(variables).length !== 0) {
logDebug('VARIABLES', variables);
}
logDebug('QUERY', query);
console.groupEnd();
return forward(operation);
}
return forward(operation).map((result) => {
const time = Date.now() - operation.getContext().start;
const errors = result.errors ?? result.data?.[queryName]?.errors;
const hasError = Boolean(errors);
try {
const titleArgs = formatTitle(
operationType,
schemaName,
queryName,
time,
);
getGroup(!hasError)(...titleArgs);
if (errors) {
errors.forEach((err: any) => {
logDebug(
`%c${err.message}`,
// eslint-disable-next-line twenty/no-hardcoded-colors
'color: #F51818; font-weight: lighter',
);
});
}
logDebug('HEADERS: ', headers);
if (variables && Object.keys(variables).length !== 0) {
logDebug('VARIABLES', variables);
}
logDebug('QUERY', query);
if (result.data) {
logDebug('RESULT', result.data);
}
if (errors) {
logDebug('ERRORS', errors);
}
console.groupEnd();
} catch {
// this may happen if console group is not supported
logDebug(
`${operationType} ${schemaName}::${queryName} (in ${time} ms)`,
);
if (errors) {
logError(errors);
}
}
return result;
});
});