Skip to content

Commit

Permalink
feat(core): Add server translations via plugin application bootstrap
Browse files Browse the repository at this point in the history
Co-authored-by: Kevin Mattutat <[email protected]>

Closes #810
  • Loading branch information
Draykee authored Apr 12, 2021
1 parent 0e9f528 commit 13a4b68
Show file tree
Hide file tree
Showing 11 changed files with 313 additions and 28 deletions.
65 changes: 65 additions & 0 deletions docs/content/docs/developer-guide/translations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
title: "Translation"
showtoc: true
---

# Translation

Using [`addTranslation`]({{< relref "i18n-service" >}}#addtranslation) inside the `onApplicationBootstrap` ([Nestjs lifecycle hooks](https://docs.nestjs.com/fundamentals/lifecycle-events)) of a Plugin is the easiest way to add new translations.
While vendure is only using `error`, `errorResult` and `message` resource keys you are free to use your own.

## Translatable Error
This example shows how to create a custom translatable error
```typescript
/**
* Custom error class
*/
class CustomError extends ErrorResult {
readonly __typename = 'CustomError';
readonly errorCode = 'CUSTOM_ERROR';
readonly message = 'CUSTOM_ERROR'; //< looks up errorResult.CUSTOM_ERROR
}

@VendurePlugin({
imports: [PluginCommonModule],
providers: [I18nService],
// ...
})
export class TranslationTestPlugin implements OnApplicationBootstrap {

constructor(private i18nService: I18nService) {

}

onApplicationBootstrap(): any {
this.i18nService.addTranslation('en', {
errorResult: {
CUSTOM_ERROR: 'A custom error message',
},
anything: {
foo: 'bar'
}
});

this.i18nService.addTranslation('de', {
errorResult: {
CUSTOM_ERROR: 'Eine eigene Fehlermeldung',
},
anything: {
foo: 'bar'
}
});

}
}
```

To receive an error in a specific language you need to use the `languageCode` query parameter
`query(QUERY_WITH_ERROR_RESULT, { variables }, { languageCode: LanguageCode.de });`

## Use translations

Vendures uses the internationalization-framework [i18next](https://www.i18next.com/).

Therefore you are free to use the i18next translate function to [access keys](https://www.i18next.com/translation-function/essentials#accessing-keys) \
`i18next.t('error.any-message');`
5 changes: 5 additions & 0 deletions packages/core/e2e/fixtures/i18n/de.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"errorResult": {
"NEW_ERROR": "Neuer Fehler"
}
}
5 changes: 5 additions & 0 deletions packages/core/e2e/fixtures/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"errorResult": {
"NEW_ERROR": "New Error"
}
}
84 changes: 84 additions & 0 deletions packages/core/e2e/fixtures/test-plugins/translation-test-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { OnApplicationBootstrap } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { Ctx, ErrorResult, I18nService, PluginCommonModule, RequestContext, VendurePlugin } from '@vendure/core';
import gql from 'graphql-tag';
import path from 'path';

class CustomError extends ErrorResult {
readonly __typename = 'CustomError';
readonly errorCode = 'CUSTOM_ERROR';
readonly message = 'CUSTOM_ERROR';
}

class NewError extends ErrorResult {
readonly __typename = 'NewError';
readonly errorCode = 'NEW_ERROR';
readonly message = 'NEW_ERROR';
}

@Resolver()
class TestResolver {

@Query()
async customErrorMessage(@Ctx() ctx: RequestContext, @Args() args: any) {
return new CustomError();
}

@Query()
async newErrorMessage(@Ctx() ctx: RequestContext, @Args() args: any) {
return new NewError();
}

}

export const CUSTOM_ERROR_MESSAGE_TRANSLATION = 'A custom error message';

@VendurePlugin({
imports: [PluginCommonModule],
providers: [I18nService],
adminApiExtensions: {
schema: gql`
extend type Query {
customErrorMessage: CustomResult
newErrorMessage: CustomResult
}
type CustomError implements ErrorResult {
errorCode: ErrorCode!
message: String!
}
type NewError implements ErrorResult {
errorCode: ErrorCode!
message: String!
}
"Return anything and the error that should be thrown"
union CustomResult = Product | CustomError | NewError
`,
resolvers: [TestResolver],
},
})
export class TranslationTestPlugin implements OnApplicationBootstrap {

constructor(private i18nService: I18nService) {

}

onApplicationBootstrap(): any {
this.i18nService.addTranslation('en', {
errorResult: {
CUSTOM_ERROR: CUSTOM_ERROR_MESSAGE_TRANSLATION,
},
});

this.i18nService.addTranslation('de', {
errorResult: {
CUSTOM_ERROR: 'DE_' + CUSTOM_ERROR_MESSAGE_TRANSLATION,
},
});

this.i18nService.addTranslationFile('en', path.join(__dirname, '../i18n/en.json'))
this.i18nService.addTranslationFile('de', path.join(__dirname, '../i18n/de.json'))
}
}
86 changes: 86 additions & 0 deletions packages/core/e2e/translations.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { LanguageCode, mergeConfig } from '@vendure/core';
import { createTestEnvironment } from '@vendure/testing';
import gql from 'graphql-tag';
import path from 'path';

import { initialData } from '../../../e2e-common/e2e-initial-data';
import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';

import * as DE from './fixtures/i18n/de.json';
import * as EN from './fixtures/i18n/en.json';
import {
CUSTOM_ERROR_MESSAGE_TRANSLATION,
TranslationTestPlugin,
} from './fixtures/test-plugins/translation-test-plugin';

describe('Translation', () => {
const { server, adminClient } = createTestEnvironment(
mergeConfig(testConfig, {
plugins: [TranslationTestPlugin],
}),
);

beforeAll(async () => {
await server.init({
initialData,
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
customerCount: 0,
});
await adminClient.asSuperAdmin();
}, TEST_SETUP_TIMEOUT_MS);

afterAll(async () => {
await server.destroy();
});

describe('translations added manualy', () => {
it('shall receive custom error message', async () => {
const { customErrorMessage } = await adminClient.query(CUSTOM_ERROR);
expect(customErrorMessage.errorCode).toBe('CUSTOM_ERROR');
expect(customErrorMessage.message).toBe(CUSTOM_ERROR_MESSAGE_TRANSLATION);
});

it('shall receive german error message', async () => {
const { customErrorMessage } = await adminClient.query(CUSTOM_ERROR, {}, { languageCode: LanguageCode.de });
expect(customErrorMessage.errorCode).toBe('CUSTOM_ERROR');
expect(customErrorMessage.message).toBe('DE_' + CUSTOM_ERROR_MESSAGE_TRANSLATION);
});
});

describe('translation added by file', () => {
it('shall receive custom error message', async () => {
const { newErrorMessage } = await adminClient.query(NEW_ERROR);
expect(newErrorMessage.errorCode).toBe('NEW_ERROR');
expect(newErrorMessage.message).toBe(EN.errorResult.NEW_ERROR);
});

it('shall receive german error message', async () => {
const { newErrorMessage } = await adminClient.query(NEW_ERROR, {}, { languageCode: LanguageCode.de });
expect(newErrorMessage.errorCode).toBe('NEW_ERROR');
expect(newErrorMessage.message).toBe(DE.errorResult.NEW_ERROR);
});
});

});

const CUSTOM_ERROR = gql`
query CustomError {
customErrorMessage {
... on ErrorResult {
errorCode
message
}
}
}
`;

const NEW_ERROR = gql`
query NewError {
newErrorMessage {
... on ErrorResult {
errorCode
message
}
}
}
`;
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@
"http-proxy-middleware": "^1.0.5",
"i18next": "^19.8.1",
"i18next-express-middleware": "^2.0.0",
"i18next-fs-backend": "^1.1.1",
"i18next-icu": "^1.4.2",
"i18next-node-fs-backend": "^2.1.3",
"image-size": "^0.9.1",
"mime-types": "^2.1.27",
"ms": "^2.1.2",
Expand Down
59 changes: 57 additions & 2 deletions packages/core/src/i18n/i18n.service.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Handler, Request } from 'express';
import * as fs from 'fs';
import { GraphQLError } from 'graphql';
import i18next, { TFunction } from 'i18next';
import i18nextMiddleware from 'i18next-express-middleware';
import Backend from 'i18next-fs-backend';
import ICU from 'i18next-icu';
import Backend from 'i18next-node-fs-backend';
import path from 'path';

import { GraphQLErrorResult } from '../common/error/error-result';
import { Logger } from '../config';
import { ConfigService } from '../config/config.service';

import { I18nError } from './i18n-error';

/**
* @description
* I18n resources used for translations
*
* @docsCategory Translation
*/
export interface VendureTranslationResources {
error: any;
errorResult: any;
message: any;
}

export interface I18nRequest extends Request {
t: TFunction;
}
Expand All @@ -21,11 +35,19 @@ export interface I18nRequest extends Request {
* The `i18next-express-middleware` middleware detects the client's preferred language based on
* the `Accept-Language` header or "lang" query param and adds language-specific translation
* functions to the Express request / response objects.
* @docsCategory Translation
*/
@Injectable()
export class I18nService implements OnModuleInit {
/**
* @internal
* @param configService
*/
constructor(private configService: ConfigService) {}

/**
* @internal
*/
onModuleInit() {
return i18next
.use(i18nextMiddleware.LanguageDetector)
Expand All @@ -35,7 +57,7 @@ export class I18nService implements OnModuleInit {
preload: ['en', 'de'],
fallbackLng: 'en',
detection: {
lookupQuerystring: 'lang',
lookupQuerystring: 'languageCode',
},
backend: {
loadPath: path.join(__dirname, 'messages/{{lng}}.json'),
Expand All @@ -44,12 +66,44 @@ export class I18nService implements OnModuleInit {
});
}

/**
* @internal
*/
handle(): Handler {
return i18nextMiddleware.handle(i18next);
}

/**
* @description
* Add a I18n translation by json file
*
* @param langKey language key of the I18n translation file
* @param filePath path to the I18n translation file
*/
addTranslationFile(langKey: string, filePath: string): void {
try {
const rawData = fs.readFileSync(filePath);
const resources = JSON.parse(rawData.toString('utf-8'));
this.addTranslation(langKey, resources);
} catch (err) {
Logger.error(`Could not load resources file ${filePath}`, `I18nService`);
}
}

/**
* @description
* Add a I18n translation (key-value) resource
*
* @param langKey language key of the I18n translation file
* @param resources key-value translations
*/
addTranslation(langKey: string, resources: VendureTranslationResources | any): void {
i18next.addResourceBundle(langKey, 'translation', resources, true, true);
}

/**
* Translates the originalError if it is an instance of I18nError.
* @internal
*/
translateError(req: I18nRequest, error: GraphQLError) {
const originalError = error.originalError;
Expand All @@ -73,6 +127,7 @@ export class I18nService implements OnModuleInit {

/**
* Translates the message of an ErrorResult
* @internal
*/
translateErrorResult(req: I18nRequest, error: GraphQLErrorResult) {
const t: TFunction = req.t;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './i18n.service';
export * from './i18n-error';
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './process-context/index';
export * from './entity/index';
export * from './data-import/index';
export * from './service/index';
export * from './i18n/index';
export * from '@vendure/common/lib/shared-types';
export {
Permission,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ declare module 'i18next-icu' {
// default
}

declare module 'i18next-node-fs-backend' {
declare module 'i18next-fs-backend' {
// default
}
Loading

0 comments on commit 13a4b68

Please sign in to comment.