Add server translation (#9847)
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" />
This commit is contained in:
@ -1,3 +1,4 @@
|
||||
import { isDefined } from 'class-validator';
|
||||
import { Plugin } from 'graphql-yoga';
|
||||
|
||||
export type CacheMetadataPluginConfig = {
|
||||
@ -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) =>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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 {}
|
||||
@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
|
||||
import { messages as enMessages } from 'src/engine/core-modules/i18n/locales/generated/en.js';
|
||||
import { messages as frMessages } from 'src/engine/core-modules/i18n/locales/generated/fr.js';
|
||||
|
||||
@Injectable()
|
||||
export class I18nService implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
i18n.load('fr', frMessages);
|
||||
i18n.load('en', enMessages);
|
||||
|
||||
i18n.activate('en');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2025-01-25 21:24+0100\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: en\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:56
|
||||
#~ msgid "Company"
|
||||
#~ msgstr "Company"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:57
|
||||
#~ msgid "Companies"
|
||||
#~ msgstr "Companies"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:58
|
||||
#~ msgid "A company"
|
||||
#~ msgstr "A company"
|
||||
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:58
|
||||
msgid "A company"
|
||||
msgstr "A company"
|
||||
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:57
|
||||
msgid "Companies"
|
||||
msgstr "Companies"
|
||||
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:56
|
||||
msgid "Company"
|
||||
msgstr "Company"
|
||||
@ -0,0 +1,20 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2025-01-26 21:19+0100\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: fr\n"
|
||||
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:58
|
||||
msgid "A company"
|
||||
msgstr "Une entreprise"
|
||||
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:57
|
||||
msgid "Companies"
|
||||
msgstr "Entreprises"
|
||||
|
||||
#: src/modules/company/standard-objects/company.workspace-entity.ts:56
|
||||
msgid "Company"
|
||||
msgstr "Entreprise"
|
||||
@ -0,0 +1 @@
|
||||
/*eslint-disable*/module.exports={messages:JSON.parse("{\"Company\":[\"Company\"],\"Companies\":[\"Companies\"],\"A company\":[\"A company\"],\"kZR6+h\":[\"A company\"],\"s2QZS6\":[\"Companies\"],\"7i8j3G\":[\"Company\"]}")};
|
||||
@ -0,0 +1 @@
|
||||
/*eslint-disable*/module.exports={messages:JSON.parse("{\"Company\":[\"Entreprise\"],\"Companies\":[\"Entreprises\"],\"A company\":[\"Une entreprise\"],\"kZR6+h\":[\"Une entreprise\"],\"s2QZS6\":[\"Entreprises\"],\"7i8j3G\":[\"Entreprise\"]}")};
|
||||
@ -1,8 +1,6 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import { expect, jest } from '@jest/globals';
|
||||
|
||||
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
|
||||
|
||||
describe('ImpersonateGuard', () => {
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Mutation,
|
||||
Parent,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
@ -22,6 +29,30 @@ export class ObjectMetadataResolver {
|
||||
private readonly beforeUpdateOneObject: BeforeUpdateOneObject<UpdateObjectPayload>,
|
||||
) {}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async labelPlural(
|
||||
@Parent() objectMetadata: ObjectMetadataDTO,
|
||||
@Context() context,
|
||||
): Promise<string> {
|
||||
return this.objectMetadataService.resolveTranslatableString(
|
||||
objectMetadata,
|
||||
'labelPlural',
|
||||
context.req.headers['x-locale'],
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async labelSingular(
|
||||
@Parent() objectMetadata: ObjectMetadataDTO,
|
||||
@Context() context,
|
||||
): Promise<string> {
|
||||
return this.objectMetadataService.resolveTranslatableString(
|
||||
objectMetadata,
|
||||
'labelSingular',
|
||||
context.req.headers['x-locale'],
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => ObjectMetadataDTO)
|
||||
async deleteOneObject(
|
||||
@Args('input') input: DeleteOneObjectInput,
|
||||
|
||||
@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import console from 'console';
|
||||
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Query, QueryOptions } from '@ptc-org/nestjs-query-core';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { isDefined } from 'class-validator';
|
||||
@ -14,6 +15,7 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
|
||||
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
|
||||
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
import { UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
import {
|
||||
ObjectMetadataException,
|
||||
@ -533,4 +535,18 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async resolveTranslatableString(
|
||||
objectMetadata: ObjectMetadataDTO,
|
||||
labelKey: 'labelPlural' | 'labelSingular',
|
||||
locale: string,
|
||||
): Promise<string> {
|
||||
if (objectMetadata.isCustom) {
|
||||
return objectMetadata[labelKey];
|
||||
}
|
||||
|
||||
i18n.activate(locale);
|
||||
|
||||
return i18n._(objectMetadata[labelKey]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,6 @@ export class GraphQLHydrateRequestFromTokenMiddleware
|
||||
|
||||
const excludedOperations = [
|
||||
'GetClientConfig',
|
||||
'GetCurrentUser',
|
||||
'GetWorkspaceFromInviteHash',
|
||||
'Track',
|
||||
'CheckUserExists',
|
||||
|
||||
@ -150,5 +150,5 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
@WorkspaceIsNullable()
|
||||
@WorkspaceIsSystem()
|
||||
@WorkspaceFieldIndex({ indexType: IndexType.GIN })
|
||||
[SEARCH_VECTOR_FIELD.name]: any;
|
||||
searchVector: any;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { MessageDescriptor } from '@lingui/core';
|
||||
|
||||
import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage';
|
||||
import { BASE_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
|
||||
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
|
||||
@ -6,9 +8,9 @@ import { TypedReflect } from 'src/utils/typed-reflect';
|
||||
interface WorkspaceEntityOptions {
|
||||
standardId: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
labelSingular: MessageDescriptor | string; // Todo: remove string when translations are added
|
||||
labelPlural: MessageDescriptor | string; // Todo: remove string when translations are added
|
||||
description?: MessageDescriptor | string; // Todo: remove string when translations are added
|
||||
icon?: string;
|
||||
shortcut?: string;
|
||||
labelIdentifierStandardId?: string;
|
||||
@ -38,9 +40,18 @@ export function WorkspaceEntity(
|
||||
standardId: options.standardId,
|
||||
nameSingular: objectName,
|
||||
namePlural: options.namePlural,
|
||||
labelSingular: options.labelSingular,
|
||||
labelPlural: options.labelPlural,
|
||||
description: options.description,
|
||||
labelSingular:
|
||||
typeof options.labelSingular === 'string'
|
||||
? options.labelSingular
|
||||
: (options.labelSingular?.message ?? ''),
|
||||
labelPlural:
|
||||
typeof options.labelPlural === 'string'
|
||||
? options.labelPlural
|
||||
: (options.labelPlural?.message ?? ''),
|
||||
description:
|
||||
typeof options.description === 'string'
|
||||
? options.description
|
||||
: (options.description?.message ?? ''),
|
||||
labelIdentifierStandardId:
|
||||
options.labelIdentifierStandardId ?? BASE_OBJECT_STANDARD_FIELD_IDS.id,
|
||||
imageIdentifierStandardId: options.imageIdentifierStandardId ?? null,
|
||||
|
||||
Reference in New Issue
Block a user