We have been researching Ext-JS library. One area of concern was localisation. The preferred technique is to asynchronously load a Javascript file which after-the-fact modifies class prototypes to change text. Eeek! We found that there were timing problems, so how to solve it?
The Ext-JS class system provides a mechanism to
- Compose classes using Mix-ins.
- Ensure that the required Javascript files are loaded in the correct order as required.
This seems ideal as long as we can control the process that serves the language mix-in so that it serves in the right language.
My test class was generated using Ext-JS’s designer and is as follows:
Ext.define('workflow.view.MyForm', { extend: 'workflow.view.ui.MyForm', mixins : ["workflow.view.MyForm_I18N"], initComponent: function() { var me = this; me.callParent(arguments); alert("View Mixin Test: " + this.text.hello); } });
An example mixin that we use, when reformatted, is
Ext.define('workflow.view.MyForm_I18N', { text:{ hello:'Hello from the Java Resource System' } });
As can be seen from my test text, my server platform is Java and I wish to use the Java resource system. This is great for our developers and our localisation team. The above was generated using a properties file.
hello = Hello from the Java Resource System
If you’re working with another server platform then you can use its technique. For example Apache has a means to serve different files depending on locale.
When Ext-JS requests a mix-in class it builds a file name of the form “{product}/app/{class.Name}.js
“. Sadly the product name is converted to lower case so we lose this information in the request. I have made my product names lower case to work around this, but if this is a problem then the server will have to be made more intelligent.
A Servlet Filter detects any request for a file within an app directory ending _I18N.js
. This is then forwarded to a Servlet which generates the mix-in code.
@WebFilter("*") public class LocaleMixinFilter implements Filter { private static final Pattern INTERESTING_FILES = Pattern.compile(".*?([^/]++)/app/(.*_I18N.js)"); // ... @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String uri = httpRequest.getRequestURI(); Matcher matcher = INTERESTING_FILES.matcher(uri); if(matcher.matches()) { String product = matcher.group(1); String name = matcher.group(2); LOG.fine(getClass(), "Forwarding localisation request for {0}/{1}", product, name); request.getRequestDispatcher(LocaleMixinServlet.ROOT + product + "/" + name).forward(request, response); } else { chain.doFilter(request, response); } } }
I’ve gone a little to town on the Servlet. I’ve not implemented cache control yet. That can happen later. I have allowed properties to be structured rather than just flat name/value pairs. For example the properties file
hello = Hello from the Java Resource System panelLabels.top = The Top panelLabels.bottom = The Bottom
results in
Ext.define('workflow.view.MyForm_I18N', { text: { hello:'Hello from the Java Resource System', panelLabels: { bottom:'The Bottom', top:'The Top' } } });
The intersting parts of the Servlet code are below. The BrowserResource class improves on ServletRequest.getResources() by taking into account our product’s supported locales.
@WebServlet("/LocaleMixin/*") public class LocaleMixinServlet extends HttpServlet { private static final int HTTP_NOT_FOUND = 404; /** Package prefix for all bundles. */ private static final String BUNDLE_PREFIX = "com.mycompany.web.ui.localemixin."; /** Pattern that a valid bundle name must match. */ private static final Pattern VALID_PATH = Pattern.compile("/([a-zA-Z_][a-zA-Z0-9_./]*)\\.js"); /** Location of the servlet. */ public static final String ROOT = "/LocaleMixin/"; // .... @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String path = request.getPathInfo(); if(path != null && path.length() > 0) { Matcher matcher = VALID_PATH.matcher(path); if(matcher.matches()) { Locale locale = BrowserLocale.getBestResourceLocale(request); String extClass = matcher.group(1).replace('/', '.'); String bundle = BUNDLE_PREFIX + extClass; LOG.fine(getClass(), "Request for {0} in {1}", bundle, locale); try { ResourceBundle resources = ResourceBundle.getBundle(bundle, locale); makeMixin(extClass, response, resources); return; } catch(MissingResourceException e) { response.sendError(HTTP_NOT_FOUND, "Could not find resources."); } } } response.sendError(HTTP_NOT_FOUND, "Could not parse request path or request path missing"); } private void makeMixin(String extClass, HttpServletResponse response, ResourceBundle resources) throws IOException, ServletException { response.setContentType("text/javascript"); Writer out = response.getWriter(); out.write("Ext.define('"); out.write(extClass); out.write("',{"); Node root = new Node("text"); Enumeration<String> keyEnum = resources.getKeys(); while(keyEnum.hasMoreElements()) { String key = keyEnum.nextElement(); String value = resources.getString(key); root.addProperty(key, value); } root.toJavascriptObject(out); out.write("});"); } }
Node.java is an example of a State Pattern, which may be the subject of another post. A simple solution would be to output a flat name/value map. That was the initial version. It would also be interesting to try an off-the-shelf JSON encoder with the ResourceBundle.
1 thought on “Internationalisation in Ext-JS using Mixins”