How to – Build your own Angular translation service – Part 3

Intro

As the title suggests, this post is part of a series. Be sure to read the first two parts:

I ended part two with resource classes containing dictionaries for the languages. In part three, I will get rid of those resource classes and replace them with json files.

Continue reading to find out the details.

Part 3

Once again I copied the end result of my previous solution and renamed everything so it’s clear that I’m working on part three.

Removing the resource classes

As I stated in the intro, I want to get rid of the actual resource classes and replace them with json files.

So, I want to go from:

import C = DeBiese.Common;

export class HomeResources extends C.Resources.ResourceBase {
	constructor() {
		super('Home');
	}

	protected setLanguageDictionary(): void {
		const self = this;
		let Translations_en: Array<C.KeyValuePair<string, string>> = [
			{ key: 'title', value: 'Home Title' },
			{ key: 'helloWorld', value: 'Hello World!' }
		];

		let Translations_nl: Array<C.KeyValuePair<string, string>> = [
			{ key: 'title', value: 'Home Titel' },
			{ key: 'helloWorld', value: 'Hallo wereld!' }
		];

		self.languageDictionary = new C.Dictionary<string, C.Dictionary<string, string>>([
			{ key: 'en', value: new C.Dictionary(Translations_en) },
			{ key: 'nl', value: new C.Dictionary(Translations_nl) }
		]);
	};        
}

To:

//home.en.json
[
{ "key": "title", "value": "Home Title" },
{ "key": "helloWorld", "value": "Hello World!" }
]

//home.nl.json
[
{ "key": "title", "value": "Home Titel" },
{ "key": "helloWorld", "value": "Hallo wereld!" }
]

This completely removes the need of the ResourceBase class. Having my translations available in json format enables me to load them using the Angular $http service. I will no longer be required to include the resource files on my index page, as there is no longer a Javascript file to include.

It brings forth another challenge however. How to tell my ResourceService which files to load.

ResourceFile

export class ResourceFile implements IResourceFile {
	fileLocation: string;
	keyPrefix: string;
	languages: Array<string>;

	constructor(resource?: IResourceFile) {
		if (resource != null) {
			this.fileLocation = resource.fileLocation;
			this.keyPrefix = resource.keyPrefix;
			this.languages = resource.languages;
		}
	}
}

The ResourceFile describes how I will configure the ResourceService going forward. One ResourceFile holds the information that was previously contained in a ResourceBase class.

  • fileLocation:
    The folder path in which the json files are stored
  • keyPrefix:
    The prefix that is used in the ResourceService when translations from this ResourceFile are added to it.
  • languages:
    An array containing the languages that are supported by this ResourceFile. For each language defined in this array, a json file should exist in the folder indicated by the fileLocation.

ResourceConfiguration

export class ResourceConfiguration implements IResourceConfiguration {
	private $resourceFiles: Array<IResourceFile>;
	preferredLanguage: string = 'en';

	constructor() {
		this.$resourceFiles = [];
	}

	addResourceFile(file: IResourceFile): void {
		const self = this;
		self.$resourceFiles.push(file);
	}

	getResourceFiles(): Array<IResourceFile>{
		const self = this;
		return self.$resourceFiles;
	}
}

The changes in the ResourceConfiguration are as expected.

  • $resourceFiles replaces $resources
  • addResourceFile replaces addResource
  • getResourceFiles replaces getResources

The main idea remains the same. We add ResourceFiles to the configuration which will result in loading translations into the ResourceService.

Typical usage of the ResourceConfiguration:

app.config((resourceServiceProvider: DeBiese.Common.Resources.ResourceProvider) => {
	let resourceConfig: DeBiese.Common.Resources.IResourceConfiguration = new DeBiese.Common.Resources.ResourceConfiguration();
	resourceConfig.preferredLanguage = 'en'; //is the default setting (could be omitted here)

	resourceConfig.addResourceFile(new DeBiese.Common.Resources.ResourceFile({ keyPrefix: 'Home', fileLocation: '/app/resources/components/home/', languages: ['en', 'nl'] }));
	resourceConfig.addResourceFile(new DeBiese.Common.Resources.ResourceFile({ keyPrefix: 'Help', fileLocation: '/app/resources/components/help/', languages: ['en', 'nl'] }));
	resourceConfig.addResourceFile(new DeBiese.Common.Resources.ResourceFile({ keyPrefix: 'Nav', fileLocation: '/app/resources/directives/navigation/', languages: ['en', 'nl'] }));
	resourceConfig.addResourceFile(new DeBiese.Common.Resources.ResourceFile({ keyPrefix: 'Error', fileLocation: '/app/resources/errors/', languages: ['en', 'nl'] }));

	resourceServiceProvider.config(resourceConfig);
});

ResourceProvider

Because I choose to work with json files, I will need to load the data in those files at runtime. That means that I need to inject the $http service into my ResourceService. Knowing that the ResourceService is instantiated by the ResourceProvider, I needed to figure out how to inject the $http service in the ResourceProvider.

export class ResourceProvider implements ng.IServiceProvider {
	static id: string = 'resourceService';
	private $resourceService: ResourceService;
	private $resourceConfiguration: IResourceConfiguration;

	constructor() {
		this.$get.$inject = ['$http'];
	}

	public config(resourceConfig: IResourceConfiguration): void {
		this.$resourceConfiguration = resourceConfig;
	}

	public $get($http: ng.IHttpService): IResourceService {
		this.$resourceService = new ResourceService($http);
		this.$resourceService.configure(this.$resourceConfiguration);
		return this.$resourceService;
	}	
}

Injection in a Provider is done by injection into the $get function. In the constructor of the provider the $inject property can be set on the $get function.

Nothing else has changed in the ResourceProvider.

ResourceService

class ResourceService implements IResourceService {
	$inject: Array<string> = ['$http'];

	private $resourceDictionary: Dictionary<string, Dictionary<string, string>> = null;
	private $dictionary: Dictionary<string, string> = null;
	private $loadedResourceFiles: Dictionary<string, IResourceFile> = null;

	constructor(private $http: ng.IHttpService) {
		this.$resourceDictionary = new Dictionary<string, Dictionary<string, string>>();
		this.$dictionary = new Dictionary<string, string>();
		this.$loadedResourceFiles = new Dictionary<string, IResourceFile>();
	}

	private loadResourceFile(resourceFile: IResourceFile): ng.IPromise<boolean> {
		const self = this; 
		let defer = Q.defer();
		let loadPromises: Array<any> = [];

		if (!self.$loadedResourceFiles.containsKey(resourceFile.keyPrefix)) {
			resourceFile.languages.forEach(lang => {
				loadPromises.push(self.loadLanguageFile(resourceFile, lang));
			});                
		} 

		Q.all(loadPromises).then(() => {
			self.$loadedResourceFiles.add(resourceFile.keyPrefix, resourceFile);
			defer.resolve(true);
		});

		return defer.promise;
	}

	private loadLanguageFile(resourceFile: IResourceFile, languageToLoad: string): ng.IPromise<boolean> {
		const self = this;
		let defer = Q.defer();

		self.$http.get(`${resourceFile.fileLocation}${resourceFile.keyPrefix.toLowerCase()}.${languageToLoad}.json`)
			.success((data, status, headers, config) => {
				let resources: Dictionary<string, string> = new Dictionary(data as Array<KeyValuePair<string, string>>);
				let tmpDictionary: Dictionary<string, string> = null;
				if (self.$resourceDictionary.containsKey(languageToLoad)) {
					tmpDictionary = self.$resourceDictionary.getValue(languageToLoad);
				}
				else {
					tmpDictionary = new Dictionary<string, string>();
					self.$resourceDictionary.add(languageToLoad, tmpDictionary);
				}

				resources.keys().forEach(tr => {
					tmpDictionary.add(`${resourceFile.keyPrefix}.${tr}`, resources.getValue(tr));
				});

				defer.resolve(true);
			})
			.error((data, status, headers, config) => {
				//TODO: do something with this?
				console.log('ERROR:');
				console.log(data);
				defer.resolve(false);
			});

		return defer.promise;
	}

	addResourceFile(resourceFile: IResourceFile): void {
		const self = this;
		self.loadResourceFile(resourceFile);
	}

	configure(resourceConfig: IResourceConfiguration): void {
		const self = this;
		if (resourceConfig != null) {
			if (resourceConfig.getResourceFiles().length === 0)
				throw new Error('At least one resource file must be configured!');
			if (resourceConfig.preferredLanguage === '')
				throw new Error('A preferred language must be set');
		}
		else {
			throw new Error('Resource configuration can not be NULL!');
		}


		let loadPromises: Array<any> = [];
		resourceConfig.getResourceFiles().forEach(rf => {
			self.loadResourceFile(rf);            
		});

		Q.all(loadPromises).then(() => { self.setLanguage(resourceConfig.preferredLanguage); });
	}

	getLocalResource(resourceKey: string): string {
		const self = this;

		if (self.$dictionary.containsKey(resourceKey))
			return self.$dictionary.getValue(resourceKey);
		else
			return resourceKey;
	}

	setLanguage(language: string): void {
		const self = this;
		if (self.$resourceDictionary.containsKey(language))
			self.$dictionary = self.$resourceDictionary.getValue(language);
		else
			throw new Error(`Language ${language} has not been configured!`);
	}
}

Similar to part two, I will explain each piece of code. Where possible I’ll refer to part two and only highlight the difference this time around.

Declarations and constructor

The service now contains a third dictionary:

  • $loadedResourceFiles:
    Used to store the ResourceFiles that are loaded in the service. This dictionary is added to support the loading of ResourceFiles at another time than during the configuration phase. An example of this follows later on in the Using the ResourceService

The constructor instantiates each dictionary as an empty dictionary.

AddResourceFile

Adds a single ResourceFile to the ResourceService by calling the private function loadResourceFile. Explanation of the private functions follows below.

Configure

I loop through all the ResourceFiles provided to the configure function and call the private function loadResourceFile for each one.

Because loading the json files is done using the $http service, this is an asynchronous action. The loadResourceFile function returns a promise. I collect each promise in an array and wait for each promise to return with Q.all before I set the language.

SetLanguage

There are no changes in this method.

GetLocalResource

I did one small change in this function. Instead of returning the string Unknown ResourceKey for an unknown key, I now return the key itself. I prefer to know which key is not found instead of just knowing that a certain key is not found.

LoadResourceFile

This function will initiate the load of the json files associated with the provided ResourceFile, if the $loadedResourceFile dictionary not already contains this ResourceFile.

I loop through each provided language and call another private function loadLanguageFile. This second private function will do the heavy lifting.

LoadLanguageFile

This function uses the $http service to get the data from the json file.

The json files should be named using the following naming convention: keyPrefix.language.json .
An example: home.en.json – home.nl.json

Once the data of the json file is returned, it is loaded into the $resourceDictionary.

Using the ResourceService

Usage of the ResourceService remains the same. The only exception is when I want to delay loading certain ResourceFiles.

In a large(r) application, it might be useful to not load all the ResourceFiles during the configuration phase of the application. When that’s the case, it might be an advantage to choose to load the ResourceFile of a certain component when this component is used the first time.

The $onInit function on the controller of a component is ideal in this scenario.

$onInit(): void {
	const self = this;
	self.resourceSvc.addResourceFile(new DeBiese.Common.Resources.ResourceFile(
		{ 
			keyPrefix: 'Home', 
			fileLocation: '/app/resources/components/home/', 
			languages: ['en', 'nl'] 
		}
	));

	self.activate();
}

Changing the language

I did not provide an actual implementation of changing the language in the sample code of part two. So, I added this in this part. Check out the Navigation directive in the application.

And that’s it. The Angular application is now translated using json files.

Summary

  • ResourceFile
  • ResourceConfiguration
    • Use ResourceFile instead of ResourceBase classes
  • ResourceProvider
    • Inject $http service
  • ResourceService
    • Inject $http service
    • Load json files
    • Added option to add a ResourceFile later in the application.

Closing word

In this part I showed you how to use json files for the translations instead of dictionaries defined in code. I also provided a way of loading the translations when necessary so not every translation needs to be loaded during configuration.

In the next part I will focus on some nice to haves. The most important one will probably be a helper application for creating and editing the json files. Stay tuned!

The full code base of this little test project can be found on GitHub.

Feel free to comment down below!

 

Ruben Biesemans

Ruben Biesemans

Analyst Developer @ Spikes

Advertisements

One response to “How to – Build your own Angular translation service – Part 3

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s