Last Updated Jan 2020 for JET 8
Once you start to get serious about building truly reusable components, the issue of language support will come to the fore. The JET components that you use within your custom components will of course do the right thing based on the language and locale settings of the application, however, what about your own resources?
In the context of a JET application, an application developer will usually define translatable resources using the following steps:
Both of these restrictions make it impossible for a custom component to magically merge its strings into the default JET bundle for access via oj.Translations.getTranslatedString() and oj.Translations.gerResource(). So what should you do?
Well the good news here is that we've automated pretty much all of this if you use the ojet create component command to scaffold up your new components. If you need to do this manually, then here's what it has done for you
When the component is created you'll notice in the component folder structure includes a /resources folder, you might have a structure a bit like this (using our standard example component ccdemo-name-badge.)
\ccdemo-name-badge
\resources
\nls
ccdemo-name-badge-strings.js
ccdemo-name-badge-styles.css
ccdemo-name-badge-view.html
ccdemo-name-badge-viewModel.js
component.json
loader.js
README.md
If you then take a peek at that generated ccdemo-name-badge-strings.js file you see it will look like this:
define({
"root": {
"ccdemo-name-badge" : {
"sampleString": "The strings file can be used to manage translatable resources"
}
}
});
So it contains just one sample string in just the root (aka default) locale. The available strings are name-spaced within an object with the same name as the component. This is important and prevents resource key naming collisions if more than one bundle is being used, so stick to that standard.
You can then update this file to add further strings and locales as required e.g. Assuming that my root language is US English I might have a resource bundle file like this:
define({
"root": {
"ccdemo-name-badge" : {
"color-error": "Selected color is not a valid CSS color code"
}
},
"en-gb": {
"ccdemo-name-badge" : {
"color-error": "Selected colour is not a valid CSS colour code"
}
},
"fr": {
"ccdemo-name-badge" : {
"color-error": "La couleur sélectionnée n'est pas un code de couleur CSS valide"
}
}
});
Now my component supports two flavours of English (US and British) and French. You can also split this bundle up to make it easier to manage the translation files. To do that, we simplify this base ccdemo-name-badge-strings.js to read as follows:
define({
"root": true,
"en-gb": true,
"fr": true
});
In this version, the base bundle just declares the supported languages. Then we have individual bundle files in subdirectories, one for each supported locale, so the folder structure changes to:
\ccdemo-name-badge
\resources
\nls
ccdemo-name-badge-strings.js
/root
ccdemo-name-badge-strings.js
/en-gb
ccdemo-name-badge-strings.js
/fr
ccdemo-name-badge-strings.js
ccdemo-name-badge-styles.css
ccdemo-name-badge-view.html
ccdemo-name-badge-viewModel.js
component.json
loader.js
README.md
Each of the locale specific bundles will then contain just the resources for that locale. For example the french version (/resources/nls/fr/ccdemo-name-badge-strings.js) would contain:
define({
"ccdemo-name-badge" : {
"color-error": "La couleur sélectionnée n'est pas un code de couleur CSS valide"
}
});
Note that there is no need for the "fr" locale object to be mentioned as that is implied by the path.
So that's how the string bundles are defined, now let's look at how they are picked up by the ViewModel.
If you look at the define() block of a scaffolded component you will pretty easily see the way that translatable strings are accessed. Here's an extract from the generated code:
define(
['knockout',
'ojL10n!./resources/nls/ccdemo-name-badge-strings',
'ojs/ojcontext',
'ojs/ojknockout'],
function (ko, componentStrings, Context) {
var self = this;
...
self.res = componentStrings['ccdemo-name-badge'];
...
}
...
});
So what's happening here?
First of all you see the reference to ojL10n!./resources/nls/ccdemo-name-badge-strings in the define block. This uses a special requireJS plugin (ojL10n) to automatically resolve the correct language bundle based on the locale of the browser (generally set via the HTML lang value). That plugin does all the hard work and understands either of the two disk layouts shown above. It will return just the language subset required into that componentStrings parameter that the constructor has access to.
Second the strings that are specific to this component are extracted into a variable stored on the component instance called res
A quick side note here about the bundle that is returned to you by the ojL10n plugin. The process is not as simple as selecting the correct bundle and returning that. What it actually does is merge the required locale bundle with the root bundle and return the combined results (in fact it may do a three layer merge e.g. fr-ca -> fr ->root ) The purpose of this merging is to provide a fallback if a given locale bundle does not include values for all the keys. It goes without saying therefore that your root should be the superset of all keys and then the locale specific bundles will override as many of those keys as is required.
At this point the ViewModel now has an instance variable "res" which provides access to the translated strings. This can be used from your JavaScript code or even directly from the custom component view html. For example you might have in input text:
<oj-input-text value="{{$properties.myValue}}" label-hint="[[res.labelKey]]">
You'll note that because you have direct access to the string bundle via the injected res reference, you don't need to access the string through the Translations.getTranslatedString() API. That's good because your code is somewhat less verbose, however, what about the token substitution feature of that API? Fortunately, this feature is available separately through the Translations.applyParameters(pattern, parameters) API. So you can still manage your token substitutions without having to write a separate function to do so.
In some cases you might want your component user to be able to provide their own value for one of your build-in translations. For example, in a search type component, they might want to override the "No Data Found" message. The standard way to achieve this is to expose a single property on your component called translations. This property in turn then has sub-properties to expose override points for each of the strings that you want to be customizable on a per-instance basis. So in your component.json:
"translations": { "displayName": "Message Overrides", "type": "object", "description": "Allows the customization of hints and error messages used by the component.", "properties": { "noDataFound": { "description": "Override the no results message.", "type": "string" }, "tooManyHits": { "description": "Override of the too-wide-a-search message.", "type": "string" } ... } }
Of course you do not have to offer overrides for all of the strings used by the component, the exact list is up to you.
If you allow overrides then in the constructor and propertyChanged callback of your component you should simply update the self.res object with the injected overrides.
If you've just arrived at Custom JET Components and would like to learn more, then you can access the whole series of articles on the topic from the Custom JET Component Learning Path