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

Intro

In part one, which you can find here, I covered centralizing static text in a resources folder.

In this second part, I will add support for multiple languages and I will build a service for using those translations throughout the application.

Let’s get started!

Part 2

Setting up the project

I pick up where I left in part one with my code. For the sake of clarity, I renamed my solution, project and modules to reflect that the code accompanies part two in this series.

howto-ngresoursec-part2-01

Dictionaries

First thing to figure out is how to go from static string declarations to supporting multiple string values per declaration. Let me rephrase that.

How can I assign multiple possible text values to a single declared key and pick the right value for that key depending on the chosen language?

I chose to work with dictionaries containing key-value pairs. This means that I will use a dictionary containing all the supported languages with a key indicating the current language. The value part is also a dictionary which will hold a unique identifier of the translated text as key and the translated text itself as value.

An example will clear this all up:

howto-ngresoursec-part2-02

There’s no such thing as a dictionary in Typescript, but it’s possible to implement one. In all fairness, I didn’t implement one myself, because I found an implementation that suits my needs right here.

I added a few things such as:

  • A constructor that takes an array of KeyValuePairs
    constructor(values?: Array<KeyValuePair<T, U>>) {
    	if (values != null) {
    		this.load(values);
    	}
    }
    
  • A method clear that removes all items from the dictionary
    public clear(): void {
    	this._keys = [];
    	this._values = [];
    }
    
  • A method load that also takes an array of KeyValuePairs which will be added to the dictionary. The load method optionally clears the dictionary first.
    public load(values: Array<KeyValuePair<T, U>>, clearDictionary?: boolean): void {
    	const self = this;
    
    	if (clearDictionary != null && clearDictionary == true)
    		self.clear();
    
    	values.forEach(kvp => {
    		self.add(kvp.key, kvp.value);
    	});
    }
    

I also created the KeyValuePair object that I used.

export class KeyValuePair<T extends number | string, U extends any>{
	key: T;
	value: U;

	constructor(key: T, value: U) {
		this.key = key;
		this.value = value;
	}
}

Resource Classes

The resource classes from part one need a complete rewrite. I wanted as little code as possible in the resource classes themselves. That’s why I created a ResourceBase class which contains all the code necessary to make the resource classes functional.

The base class:

export abstract class ResourceBase {
	protected resourceName: string;
	protected languageDictionary: Dictionary<string, Dictionary<string, string>> = null;

	constructor(name: string) {
		this.resourceName = name;
		this.setLanguageDictionary();
	}

	protected setLanguageDictionary(): void {
		throw new Error('setLanguageDictionary not implemented!');
	};

	getLanguages(): Dictionary<string, Dictionary<string, string>> {
		const self = this;
		return self.languageDictionary;
	}

	getResourceName(): string {
		const self = this;
		return self.resourceName;
	}

	getTranslations(language: string): Dictionary<string, string> {
		const self = this;
		if (self.languageDictionary == null)
			self.setLanguageDictionary();
		if (self.languageDictionary.containsKey(language))
			return self.languageDictionary.getValue(language);
		else
			throw new Error(`Language '${language}' does not exist.`);
	}
}

The base class holds a resourceName (string) and a languageDictionary (Dictionary). The methods that get information out of the class are implemented. The method that sets the information throws an error, because the classes implementing this base class are supposed to implement this part.

The resource class for my HomeComponent:

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) }
		]);
	};
}

In the constructor I call the constructor of the base class, this sets the resourceName which will be used later on by our resource service. The resourceName will be used as a prefix when adding the dictionary of this one resource class into the global dictionary that is created in the ResourceService at runtime.

I override the setLanguageDictionary method and create a dictionary for every language I wish to support. Those language dictionaries are added to the languageDictionary of the resource class.

Resource Provider

I want to configure my resources before starting the application, so I’m creating a ResourceProvider. If you are not familiar with providers in Angular, you can find more information here.

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

	constructor() {
		this.$resourceService = new ResourceService();
	}

	public config(resourceConfig: IResourceConfiguration): void {
		this.$resourceService.configure(resourceConfig);
	}

	public $get(): IResourceService {
		return this.$resourceService;
	}
}

angular.module('debiese.common', [])
	.provider(DeBiese.Common.Resources.ResourceProvider.id, DeBiese.Common.Resources.ResourceProvider);

The provider will hold an instance of my ResourceService and accepts a ResourceConfiguration object for configuration of the service.

Resource Configuration

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

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

	addResource(resource: ResourceBase): void {
		const self = this;
		self.$resources.push(resource);
	}

	getResources(): Array<ResourceBase> {
		const self = this;
		return self.$resources;
	}
}

The ResourceConfiguration can be used to set the preferredLanguage of the application. By default this will be ‘en’ (English). Besides that, an array of ResourceBase instances can be added.

Typical usage of this configuration:

//Resource configuration
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.addResource(new DeBiese.NgResources.Part2.Resources.HelpResources());
	resourceConfig.addResource(new DeBiese.NgResources.Part2.Resources.HomeResources());
	resourceConfig.addResource(new DeBiese.NgResources.Part2.Resources.NavigationResources());
	resourceConfig.addResource(new DeBiese.NgResources.Part2.Resources.ErrorResources());
	resourceServiceProvider.config(resourceConfig);
});

Resource Service

export interface IResourceService {
	getLocalResource: (resourceKey: string, resourceFile?: string) => string;
	setLanguage: (language: string) => void;
}

class ResourceService implements IResourceService {
        private $resourceDictionary: Dictionary<string, Dictionary<string, string>> = null;
        private $dictionary: Dictionary<string, string> = null;

        constructor() {
            this.$resourceDictionary = new Dictionary<string, Dictionary<string, string>>();
        }

        configure(resourceConfig: IResourceConfiguration): void {
            const self = this;
            if (resourceConfig != null) {
                if (resourceConfig.getResources().length === 0)
                    throw new Error('At least one resource 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!');
            }

            resourceConfig.getResources().forEach(rb => {
                if (rb.getLanguages() != null && rb.getLanguages().count() > 0) {
                    let languageDictionary = rb.getLanguages();
                    languageDictionary.keys().forEach(k => {
                        let tmpDictionary: Dictionary<string, string> = null;
                        if (self.$resourceDictionary.containsKey(k)) {
                            tmpDictionary = self.$resourceDictionary.getValue(k);
                        }
                        else {
                            tmpDictionary = new Dictionary<string, string>();
                            self.$resourceDictionary.add(k, tmpDictionary);
                        }

                        let translationDictionary = languageDictionary.getValue(k);
                        if (translationDictionary != null && translationDictionary.count() > 0) {
                            translationDictionary.keys().forEach(tr => {
                                tmpDictionary.add(`${rb.getResourceName()}.${tr}`, translationDictionary.getValue(tr));
                            });
                        }
                    });
                }
            });

            self.setLanguage(resourceConfig.preferredLanguage);
        }

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

            if (self.$dictionary.containsKey(resourceKey))
                return self.$dictionary.getValue(resourceKey);
            else
                return 'Unknown 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!`);
        }
    }

The last piece of the puzzle, the ResourceService. It’s important to point out that only the IResourceService interface is exported. This is not a typo or oversight on my part. This means that only the methods exposed through the interface are accessible in the application.

The configure method is called by the ResourceProvider during the configuration phase of the provider.

Let me explain the service piece by piece.

Declarations and constructor

The service contains two dictionaries:

  • $resourceDictionary:
    Used to store each language with its respective dictionary of translations
  • $dictionary:
    The currently active dictionary of translations. When the language is changed in the application, the value of this dictionary will change to reflect the chosen language

The constructor instantiates the $resourceDictionary as an empty dictionary.

Configure

First some basic checks are executed. Nothing fancy. Once past the checks, I start building up the $resourceDictionary by looping through each ResourceBase provided.

Each ResourceBase contains a languageDictionary. I loop through each language in this dictionary. If the $resourceDictionary already contains a dictionary for this language, I use it, otherwise I create a new dictionary and add it to the $resourceDictionary.

Now I loop through the dictionary for each language and add each translation into this one list. As said before, I add the resourceName of the ResourceBase class as a prefix.

SetLanguage

This simply sets the value of $dictionary if in fact the provided language exists in the $resourceDictionary. If not an error is thrown.

GetLocalResource

Returns the translation, value as you will, from the $dictionary for the provided (resource)Key if the key exists. Otherwise a default string ‘Unknown resourcekey’ is returned.

Using the ResourceService

To use the ResourceService in your component, you inject the ResourceProvider into your controller.

export class HomeController implements IHomeController {
	static $inject: string[] = [
		'$state',
		'$timeout',
		S.ToastService.id,
		S.LogService.id,
		S.DummyService.id,
		DeBiese.Common.Resources.ResourceProvider.id
	];
	
	//...

	constructor(
		private $state: angular.ui.IStateService,            
		private $timeout: ng.ITimeoutService,
		private toastSvc: S.IToastService,
		private logSvc: S.ILogService,
		private dummySvc: S.DummyService,
		private resourceSvc: DeBiese.Common.Resources.IResourceService
	) {
		//...
	}
}

With the resourceSvc on the controller, we can use this in the controller itself like so:

self.$timeout(() => {
	self.toastSvc.toastError(self.resourceSvc.getLocalResource('Home.helloWorld'))
}, 0, true);

Or in the view like so:

<div class="panel panel-default">
    <div class="panel-heading">
        <span>{{vm.resourceSvc.getLocalResource('Home.title')}}</span>
    </div>
    <div class="panel-body">
        <div class="row">
            <div class="col-lg-12">
                <h1>{{vm.resourceSvc.getLocalResource('Home.helloWorld')}}</h1>
            </div>
        </div>
    </div>
</div>

Notice that in the html I’m no longer using one-way binding. That’s because I want to be able to switch the language on the go. The translations should be loaded immediately. So one-way binding is no longer an option.

Changing the language

The last part is providing the user with a language picker. Changing the language in the picker should call the setLanguage method on the ResourceService. Translations will show up immediately.

And that’s it. I have a translated Angular application.

Summary

  • Helper classes
    • KeyValuePair
    • Dictionary
    • ResourceBase
  • ResourceProvider
    • Creates ResourceService instance
    • Configures ResourceService during app configuration
    • Is injected into controllers for usage in code or html

Closing word

In this part I built upon my centralized static text to introduce translations. In Part three I’ll be looking into separating the translations into json files and possibly lazy loading them instead of loading all translations at the start of the application. 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 2

  1. Pingback: How to – Build your own Angular translation service – Part 3 | Spikes Apps·

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