Monday 23 June 2014

How to add multilingual support to a WebCenter Portal Framework application

These are our requirements:
  • The Login/Error pages need to be localized according to the browser's locale.
  • The user needs to has preferred locale associated to him (in a session variable)
  • When the user logs in, the preferred locale needs to be set as the preferred locale in the portal application: if the the preferred locale is not among the supported locales, the application default locale is applied.
  • The user needs to be able to switch locale at any moment from a dropdown menu.
  • The new locale needs to be set as the user preferred locale.
  • Both the resource bundles and the pages/fragments to be localized are not located in the portal application itself, but inside ADF shared libraries consumed from the portal application.
The starting point for the language selection solution is Frank Nimphius chapter 18 of the ADF 11g Oracle Fusion Developer Guide, and this blog post from A-Team's Martin Deh).

Description of the general solution
  • Create and register several resource bundles for the application, one for each language we need to support (plus the default bundle).
  • Define and register a session scoped bean, responsible of holding the selected locale.
  • Define and register a global custom Phase Listener, which before every RENDER_MODEL_ID phase accesses the bean, and sets the application locale to the selected one.
  • Create a portal page which will provide the user with the functionality to modify the current locale from a dropdown menu.
  • Localize the portal navigation model.
  • Set additional localization properties.
So, let's crack on with it!

1. Definition and registration of the resource bundles

Inside a separate ADF application, we define the resource bundles, one in each language we need to support. In our specific case, we support German and English languages, and so we will have 3 bundles defined:
  • one with _de suffix (es. diagnosticsBundle_de.properties)
  • one with _en suffix (es. diagnosticsBundle_en.properties)
  • one with no suffix at all (es. diagnosticsBundle_de.properties). This is selected in case none of the previous is matched.
For more information about resource bundle matching, click here.

We define one more properties file, called language.properties: this fil will store a comma separated list of supported languages for the application, and the default language for the application:

SUPPORTED_LOCALES=de,en
DEFAULT_LOCALE=de

In our case, these bundles are defined inside a separate ADF application, that is then packaged into an ADF LIbrary JAR file:

<SCREENSHOT OF THE APP IN JDEV>

If your bundles need to be defined in the portal application itself, the procedure is the same.

The next step is to register the supported languages and resource bundles is WEB-INF/faces-config.xml :

<?xml version="1.0" encoding="windows-1252"?>
<faces-config version="1.2" xmlns="http://java.sun.com/xml/ns/javaee">
  <application>
 <default-render-kit-id>oracle.adf.rich</default-render-kit-id>
 <resource-bundle>
   <base-name>com/example/common/bundles/applicationBundle</base-name>
   <var>applicationBundle</var>
 </resource-bundle>
 <resource-bundle>
   <base-name>com/example/common/bundles/errorBundle</base-name>
   <var>errorBundle</var>
 </resource-bundle>
 <locale-config>
   <default-locale>de</default-locale>
   <supported-locale>en</supported-locale>
   <supported-locale>de</supported-locale>
 </locale-config>
  </application>
</faces-config>

You can define as many resource bundles as you want in here.

Of course the <var> element defines the name the bundle will be referred by in the pages/fragments of the application:

<af:outputText value="#{applicationBundle.SECTION_TITLE}" id="ot4"/>

2. Definition of the Change Language page

To introduce the Change Language functionality, we need to put the following code in a page/fragment of our portal application:
<af:selectOneChoice label="#{applicationBundle.SELECT_LANGUAGE}" id="localeSelector"
      value="#{localeBean.selectedLocale}" valuePassThru="false"
      binding="#{localeBean.langSelector}">
 <f:selectItems value="#{localeBean.suppLocales}" id="si1"/>
</af:selectOneChoice>

<af:commandButton text="#{applicationBundle.CHANGE_LANGUAGE}" id="cb1" 
      actionListener="#{localeBean.saveLanguageSettings}"/>

The EL expressions will be clearer in a bit, once we define the localeBean and we show it's content;

3. Definition of the Locale bean

In the portal application, open the WEB-INF/adfc-config.xml file and define a new session scoped managed bean:

<managed-bean>
  <managed-bean-name>localeBean</managed-bean-name>
  <managed-bean-class>com.example.portal.beans.LocaleBean</managed-bean-class>
  <managed-bean-scope>session</managed-bean-scope>
</managed-bean>

This bean will look like this:
/** 
 * The constructor reads the language.properties file and set the supported languages and the default language.
 */    
public LocaleBean(){
 ResourceBundle labels = ResourceBundle.getBundle("com.example.common.bundles.language");
 languages = labels.getString("SUPPORTED_LOCALES").split(",");
 defaultLocale = labels.getString("DEFAULT_LOCALE");
}

/**
 * Gets the list of the locales supported from the application.
 * @return the List of supported locales.
 */
public List getSuppLocales() {
 suppLocales = new ArrayList();
 for(String lang : languages){
  Locale locale = new Locale(lang);
  SelectItem item = new SelectItem(locale, locale.getDisplayLanguage(locale));
  suppLocales.add(item);
 }
 return suppLocales;
}

/**
 * Changes the current locale into the selected one.
 *
 * @param language the new locale to be set.
 */
private void changeLocale(String language) {
 Locale newLocale = new Locale(language);
 FacesContext fctx = FacesContext.getCurrentInstance();
 fctx.getViewRoot().setLocale(newLocale);
}

/**
 * Checks whether the preferred language (set in OID) is among the supported ones.
 *
 * @return true if the user preferred language is supported, false otherwise.
 */
private boolean isPreferredLanguageFound(){
 preferredLanguage = JSFUtils.resolveExpression("#{sessionScope.userInfoBean.preferredLanguage}");
 if(preferredLanguage == null){
  return false;
 }
 List locales = getSuppLocales();
 for(SelectItem l : locales){
  Locale loc = (Locale)l.getValue();
  if(loc.getLanguage().equals(preferredLanguage.toString())){
   return true;
  }
 }
 return false;
}

/**
 * This method is called to set the default language of the portal.
 * If the user has a preferred language in OID, that language is set, otherwise
 * the defaul language of the application (read from language.properties) is set.
 */
public void setDefaultLocale(){
 if(isPreferredLanguageFound()){
  changeLocale(preferredLanguage.toString());
 }else{
  changeLocale(defaultLocale);
 }
}

/**
 * This method is responsible for setting the application locale to the one
 * currently selected from the dropdown menu.
 * It then sets the selected language in OID, and in Autoaid.
 *
 * @param actionEvent the Action event.
 */
public void saveLanguageSettings(ActionEvent actionEvent) {
 selectedLocale = (Locale)langSelector.getValue();
 FacesContext fctx = FacesContext.getCurrentInstance();
 fctx.getViewRoot().setLocale(selectedLocale);

 //Set the language as the preferred one
 [...]

}

As soon as the bean is instantiated, the supported languages and the default language are read from the language.properties and stored in the respective fields.

The dropdown menu is populated with the list retrieved by the getSuppLocales() method, which is retrieving the supported languages extracted from the constructor.

As you may have noticed, the list of supported locales is also defined in faces.config.xml, so the lines 5-7 of the above code could be replaced with:

FacesContext.getCurrentInstance().getApplication().getSupportedLocales();
FacesContext.getCurrentInstance().getApplication().getDefaultLocale();

This lists both supported languages and the default language from the faces-config.xml file instead of the custom properties file.
However, in more complex cases where the both resource bundles and the pages/fragments to be localized are packaged into ADF shared libraries consumed from the mail portal application, this may not work. So in this case I prefer to define the language.propeties file, which does not lose in flexibility (to modify these settings would imply a redeploy of the application anyway).

The selectedLocale variable holds the locale currently selected from the dropdown menu, and the button executes the saveLanguageSettings() method which sets the local to the selected one.

4. Definition of a global PagePhaseListener

No, we have one problem: what we have built so far would work for a single request (when we hit the Change Language the button), but at the next action which involves a page refresh, the browser locale would be set automatically in the FacesContext, losing the selection we made.

For this reason, we need to introduce a global PagePhaseListener, which is responsible for intercepting a specific phase of the lifecycle (the RENDER_MODEL_ID phase) before the page is actually rendered, lookup the selected locale in our localeBean, and force this locale into the FacesContext.

First thing, we need to create adf-settings.xml file into the .adf/META-INF folder in the application root:

<?xml version="1.0" encoding="US-ASCII" ?>
<adf-settings xmlns="http://xmlns.oracle.com/adf/config">
 <adfc-controller-config xmlns="http://xmlns.oracle.com/adf/controller/config">
   <lifecycle>
  <phase-listener>
    <listener-id>portalPhaseListener</listener-id>
    <class>com.example.portal.CustomPhaseListener</class>
  </phase-listener>
   </lifecycle>
 </adfc-controller-config>
</adf-settings>

The beforePhase method will need to be define as follows:

import oracle.adf.controller.v2.lifecycle.ADFLifecycle;
import oracle.adf.controller.v2.lifecycle.PagePhaseEvent;
import oracle.adf.controller.v2.lifecycle.PagePhaseListener;

import oracle.webcenter.navigationframework.ResourceNotFoundException;
import oracle.webcenter.portalframework.sitestructure.SiteStructure;
import oracle.webcenter.portalframework.sitestructure.SiteStructureContext;

public class CustomPhaseListener implements PagePhaseListener {

public CustomPhaseListener() {
 super();
}

public void afterPhase(PagePhaseEvent pagePhaseEvent) {
}


public void beforePhase(PagePhaseEvent event) {
 Integer phase = event.getPhaseId();
 if (phase.equals(ADFLifecycle.PREPARE_MODEL_ID)) {
  FacesContext fctx = FacesContext.getCurrentInstance();
  LocaleBean localeBean =
   (LocaleBean) fctx.getApplication().evaluateExpressionGet(fctx, "#{localeBean}", Object.class);
  Locale selectedLocale = localeBean.getSelectedLocale();
  UIViewRoot uiViewRoot = fctx.getCurrentInstance().getViewRoot();

  //if the page is login or error, don't apply the locale change logic
  if(!uiViewRoot.getViewId().contains("login.jspx") && !uiViewRoot.getViewId().contains("error.jspx")){
   if (selectedLocale == null) {
    localeBean.setDefaultLocale();
   } else {
    uiViewRoot.setLocale(selectedLocale);
   }

   //refresh the Navigation model
   try {
      SiteStructureContext ctx = SiteStructureContext.getInstance();
      SiteStructure model = ctx.getDefaultSiteStructure();
      model.invalidateCache();
   }
   catch (ResourceNotFoundException rnfe) {
      rnfe.printStackTrace();
   }
  }

 }
}

From the logic above, you can see how the logic is applied only if the page is not the login page or the error page: in that case the browser locale should prevail.

If we are in any other page, the phase listener extracts the current view root from th FacesContext, then checks whether there is a locale already selected in the localeBean: in that case the locale is applied to the view root, otherwise the setDefaultLocale() method is called: this method is responsible for setting the language to the user preferred language (if this is in the list of the supported locales) or to the default application locale (the one we read from language.properties).

The last section is necessary to refresh the navigation model of the portal application when the locale is changed: these lines use the WebCenter Portal Framework Navigation APIs. This portion of code will be clearer in the next section.

5. Localization of the portal Navigation Model

One of the core points of this article is abaout localizing the portal navigation model.

As Martin Deh's article explains, the navigation model supports localization of certain textual strings like Title for example: so defining the page titles in the resource bundles would do the trick.

However, this would not work in case the navigation model is based on the portal page hierarchy (our case indeed):

<<SCREENSHOT>>

In our approach, for every page in the navigation model we need to access its page definition file and set the <parameter> element named 'page_title' with a resource bundle key instead of the hardcoded value.

For example:

<?xml version="1.0" encoding="UTF-8" ?>
<pageDefinition xmlns="http://xmlns.oracle.com/adfm/uimodel" 
                   version="11.1.1.61.92" 
                   id="diagnosticsPageDef"
                   Package="oracle.webcenter.portalapp.pages">
  <parameters>
  [...]
    <parameter id="page_title" value="DIAGNOSTICS"/>
  </parameters>
  [...]  

It means that in the resource bundle we are supposed to have a key named DIAGNOSTICS, with the actual title translation.

Then we need to adapt the code which actually displays the navigation model. Usually, this code is defined inside the portal page template(s).

An extract of a possible code to display the navigation model is:


<c:set var="navNodes" value="${navigationContext.defaultNavigationModel.listModel['startNode=/, includeStartNode=false']}" scope="session"/>
<af:panelGroupLayout styleClass="nav" id="pt_pgl8">
  <ul class="belt">
 <c:forEach var="menu" varStatus="vs" items="${navNodes}">
  <li>
     <a href="/myApplicationContextRoot${menu.goLinkPrettyUrl}">
     ${applicationBundle[menu.title]}
     </a>
   </li>
 </c:forEach>
  </ul>
</af:panelGroupLayout>

So when we change the locale, this portion of code we defined in our CustomPhaseListener class is there to make sure that the navigation model refreshes appropriately:

SiteStructureContext ctx = SiteStructureContext.getInstance();
SiteStructure model = ctx.getDefaultSiteStructure();
model.invalidateCache();

See here the documentation for the navigation model EL APIs and here for the documentation about how to visualize the portal navigation.

6. Additional Localization properties

It's possible to add further localization properties (like number grouping separator or decimal separator, currency codes, date format, timezone etc) in the WEB-INF/trinidad-config.xml file. for example:

<number-grouping-separator>
 #{view.locale.language=='de' ? '.' : ','}
</number-grouping-separator>

<!-- Set the decimal separator to comma for German -->
<!-- and period for all other languages -->
<decimal-separator>
 #{view.locale.language=='de' ? ',' : '.'}
</decimal-separator>

For further details, refer to the documentation here.

2 comments:

  1. Matteo, if you want to localize .properties files, my advice would be to use a specialized tool, like the localization management platform https://poeditor.com/

    It can help you better organize the strings translation process, especially if you are using it for collaborative or crowdsourced localization projects, and it also works with XML files.

    Some features that I recommend checking out in particular: API, Translation Memory and GitHub/Bitbucket integration (depending on what code hosting site you use, if any). They will help you automate the l10n workflow.

    Cheers!

    ReplyDelete

  2. Output Portal Crack
    I am very impressed with your post because this post is very beneficial for me and provide a new knowledge to me

    ReplyDelete