Skip to content

Commit

Permalink
Add server translation (#9847)
Browse files Browse the repository at this point in the history
First proof of concept for server-side translation.

The goal was to translate one metadata item:

<img width="939" alt="Screenshot 2025-01-26 at 08 18 41"
src="https://github.com/user-attachments/assets/e42a3f7f-f5e3-4ee7-9be5-272a2adccb23"
/>
  • Loading branch information
FelixMalfait authored Jan 27, 2025
1 parent 2a911b4 commit 549c3fa
Show file tree
Hide file tree
Showing 35 changed files with 398 additions and 117 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.8.0",
"@swc/cli": "^0.3.12",
"@swc/core": "~1.3.100",
"@swc/core": "1.7.42",
"@swc/helpers": "~0.5.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "14.0.0",
Expand Down
12 changes: 8 additions & 4 deletions packages/twenty-front/lingui.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ export default defineConfig({
],
catalogsMergePath: '<rootDir>/src/locales/generated/{locale}',
compileNamespace: 'ts',
service: {
name: 'TranslationIO',
apiKey: process.env.TRANSLATION_IO_API_KEY ?? '',
},
...(process.env.TRANSLATION_IO_API_KEY
? {
service: {
name: 'TranslationIO',
apiKey: process.env.TRANSLATION_IO_API_KEY,
},
}
: {}),
});
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -29,6 +29,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const setCurrentUser = useSetRecoilState(currentUserState);
const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState,
Expand Down Expand Up @@ -61,6 +62,7 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
connectToDevTools: isDebugMode,
// We don't want to re-create the client on token change or it will cause infinite loop
initialTokenPair: tokenPair,
currentWorkspaceMember: currentWorkspaceMember,
onTokenPairChange: (tokenPair) => {
setTokenPair(tokenPair);
},
Expand Down Expand Up @@ -105,5 +107,11 @@ export const useApolloFactory = (options: Partial<Options<any>> = {}) => {
}
}, [tokenPair]);

useUpdateEffect(() => {
if (isDefined(apolloRef.current)) {
apolloRef.current.updateWorkspaceMember(currentWorkspaceMember);
}
}, [currentWorkspaceMember]);

return apolloClient;
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,22 @@ jest.mock('@/auth/services/AuthService', () => {
const mockOnError = jest.fn();
const mockOnNetworkError = jest.fn();

const mockWorkspaceMember = {
id: 'workspace-member-id',
locale: 'en',
name: {
firstName: 'John',
lastName: 'Doe',
},
};

const createMockOptions = (): Options<any> => ({
uri: 'http://localhost:3000',
initialTokenPair: {
accessToken: { token: 'mockAccessToken', expiresAt: '' },
refreshToken: { token: 'mockRefreshToken', expiresAt: '' },
},
currentWorkspaceMember: mockWorkspaceMember,
cache: new InMemoryCache(),
isDebugMode: true,
onError: mockOnError,
Expand All @@ -50,13 +60,21 @@ const makeRequest = async () => {
});
};

describe('xApolloFactory', () => {
describe('ApolloFactory', () => {
it('should create an instance of ApolloFactory', () => {
const options = createMockOptions();
const apolloFactory = new ApolloFactory(options);
expect(apolloFactory).toBeInstanceOf(ApolloFactory);
});

it('should initialize with the correct workspace member', () => {
const options = createMockOptions();
const apolloFactory = new ApolloFactory(options);
expect(apolloFactory['currentWorkspaceMember']).toEqual(
mockWorkspaceMember,
);
});

it('should call onError when encountering "Unauthorized" error', async () => {
const errors = [{ message: 'Unauthorized' }];
fetchMock.mockResponse(() =>
Expand Down Expand Up @@ -138,4 +156,21 @@ describe('xApolloFactory', () => {
expect(mockOnNetworkError).toHaveBeenCalledWith(mockError);
}
}, 10000);

it('should update workspace member when calling updateWorkspaceMember', () => {
const options = createMockOptions();
const apolloFactory = new ApolloFactory(options);

const newWorkspaceMember = {
id: 'new-workspace-member-id',
locale: 'fr',
name: {
firstName: 'John',
lastName: 'Doe',
},
};

apolloFactory.updateWorkspaceMember(newWorkspaceMember);
expect(apolloFactory['currentWorkspaceMember']).toEqual(newWorkspaceMember);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RetryLink } from '@apollo/client/link/retry';
import { createUploadLink } from 'apollo-upload-client';

import { renewToken } from '@/auth/services/AuthService';
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { AuthTokenPair } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { logDebug } from '~/utils/logDebug';
Expand All @@ -28,13 +29,15 @@ export interface Options<TCacheShape> extends ApolloClientOptions<TCacheShape> {
onTokenPairChange?: (tokenPair: AuthTokenPair) => void;
onUnauthenticatedError?: () => void;
initialTokenPair: AuthTokenPair | null;
currentWorkspaceMember: CurrentWorkspaceMember | null;
extraLinks?: ApolloLink[];
isDebugMode?: boolean;
}

export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
private client: ApolloClient<TCacheShape>;
private tokenPair: AuthTokenPair | null = null;
private currentWorkspaceMember: CurrentWorkspaceMember | null = null;

constructor(opts: Options<TCacheShape>) {
const {
Expand All @@ -44,12 +47,14 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
onTokenPairChange,
onUnauthenticatedError,
initialTokenPair,
currentWorkspaceMember,
extraLinks,
isDebugMode,
...options
} = opts;

this.tokenPair = initialTokenPair;
this.currentWorkspaceMember = currentWorkspaceMember;

const buildApolloLink = (): ApolloLink => {
const httpLink = createUploadLink({
Expand All @@ -64,6 +69,9 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
authorization: this.tokenPair?.accessToken.token
? `Bearer ${this.tokenPair?.accessToken.token}`
: '',
...(this.currentWorkspaceMember?.locale
? { 'x-locale': this.currentWorkspaceMember.locale }
: {}),
},
};
});
Expand Down Expand Up @@ -157,6 +165,10 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
this.tokenPair = tokenPair;
}

updateWorkspaceMember(workspaceMember: CurrentWorkspaceMember | null) {
this.currentWorkspaceMember = workspaceMember;
}

getClient() {
return this.client;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { ApolloClient } from '@apollo/client';

import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { AuthTokenPair } from '~/generated/graphql';

export interface ApolloManager<TCacheShape> {
getClient(): ApolloClient<TCacheShape>;
updateTokenPair(tokenPair: AuthTokenPair | null): void;
updateWorkspaceMember(workspaceMember: CurrentWorkspaceMember | null): void;
}
8 changes: 6 additions & 2 deletions packages/twenty-server/.swcrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
Expand All @@ -8,7 +7,12 @@
"decorators": true,
"dynamicImport": true
},
"baseUrl": "./../../"
"baseUrl": "./../../",
"experimental": {
"plugins": [
["@lingui/swc-plugin", {}]
]
}
},
"minify": false
}
23 changes: 19 additions & 4 deletions packages/twenty-server/jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { JestConfigWithTsJest } from 'ts-jest';

const jestConfig: JestConfigWithTsJest = {
const jestConfig = {
// to enable logs, comment out the following line
silent: true,
clearMocks: true,
Expand All @@ -10,7 +8,24 @@ const jestConfig: JestConfigWithTsJest = {
transformIgnorePatterns: ['/node_modules/'],
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
'^.+\\.(t|j)s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
transform: {
decoratorMetadata: true,
},
experimental: {
plugins: [['@lingui/swc-plugin', {}]],
},
},
},
],
},
moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1',
Expand Down
25 changes: 25 additions & 0 deletions packages/twenty-server/lingui.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig } from '@lingui/cli';

export default defineConfig({
sourceLocale: 'en',
locales: ['en', 'fr'],
extractorParserOptions: {
tsExperimentalDecorators: true,
},
catalogs: [
{
path: '<rootDir>/src/engine/core-modules/i18n/locales/{locale}',
include: ['src'],
},
],
catalogsMergePath:
'<rootDir>/src/engine/core-modules/i18n/locales/generated/{locale}',
...(process.env.TRANSLATION_IO_API_KEY_BACKEND
? {
service: {
name: 'TranslationIO',
apiKey: process.env.TRANSLATION_IO_API_KEY_BACKEND,
},
}
: {}),
});
14 changes: 14 additions & 0 deletions packages/twenty-server/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,20 @@
}
},
"defaultConfiguration": "seed"
},
"lingui:extract": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "lingui extract --overwrite"
}
},
"lingui:compile": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "lingui compile --typescript"
}
}
}
}
3 changes: 3 additions & 0 deletions packages/twenty-server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
import { ModulesModule } from 'src/modules/modules.module';

import { CoreEngineModule } from './engine/core-modules/core-engine.module';
import { I18nModule } from './engine/core-modules/i18n/i18n.module';

@Module({
imports: [
Expand All @@ -51,6 +52,8 @@ import { CoreEngineModule } from './engine/core-modules/core-engine.module';
CoreGraphQLApiModule,
MetadataGraphQLApiModule,
RestApiModule,
// I18n module for translations
I18nModule,
// Conditional modules
...AppModule.getConditionalModules(),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isDefined } from 'class-validator';
import { Plugin } from 'graphql-yoga';

export type CacheMetadataPluginConfig = {
Expand All @@ -12,8 +13,12 @@ export function useCachedMetadata(config: CacheMetadataPluginConfig): Plugin {
const workspaceMetadataVersion =
serverContext.req.workspaceMetadataVersion ?? '0';
const operationName = getOperationName(serverContext);
const locale = serverContext.req.headers['x-locale'] ?? '';
const localeCacheKey = isDefined(serverContext.req.headers['x-locale'])
? `:${locale}`
: '';

return `graphql:operations:${operationName}:${workspaceId}:${workspaceMetadataVersion}`;
return `graphql:operations:${operationName}:${workspaceId}:${workspaceMetadataVersion}${localeCacheKey}`;
};

const getOperationName = (serverContext: any) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';

import { expect, jest } from '@jest/globals';

import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';

const UserFindOneMock = jest.fn();
const WorkspaceFindOneMock = jest.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { BeforeCreateOneAppToken } from 'src/engine/core-modules/app-token/hooks/before-create-one-app-token.hook';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';

export enum AppTokenType {
RefreshToken = 'REFRESH_TOKEN',
CodeChallenge = 'CODE_CHALLENGE',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';

import { expect, jest } from '@jest/globals';
import bcrypt from 'bcrypt';
import { Repository } from 'typeorm';

Expand Down
10 changes: 10 additions & 0 deletions packages/twenty-server/src/engine/core-modules/i18n/i18n.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';

import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';

@Global()
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}
Loading

0 comments on commit 549c3fa

Please sign in to comment.