From 5c9a687bb7b5bb106f1d82f65193eb31fa75e0d8 Mon Sep 17 00:00:00 2001 From: Honza Pokorny Date: Thu, 13 Apr 2017 11:28:16 -0300 Subject: [PATCH] Automatically enable all available languages Right now, we have to create at least two patches for each new language that becomes available: one to update the tripleo-ui itself, and one to update the defaults in the puppet code. This is painful and takes a long time. This patch turns things around. Instead of specifying which languages should be available, we automatically load all of them, and provide users with a way of disabling some of them should they need to. When a new language is imported from the translation team into our code, there is no work necessary. This is accomplished via Webpack plugin which reads the i18n/locales directory, parses the data, and generates a Javascript file with the necessary imports and other useful objects. The "languages" setting in the config file is changed to "excludedLanguages", and it's a simple list of language abbreviations. Futhermore, this patch adds a simple script that verifies that all available languages are properly enabled i the constants file. Closes-Bug: #1682452 Change-Id: Idf5a3314c19be18ca6cabbae1e94bc7cb1d1fe94 --- .gitignore | 1 + bin/run_lint.sh | 21 ++++ bin/verify-languages.js | 61 ++++++++++++ config/tripleo_ui_config.js.sample | 16 +-- docs/translation.rst | 81 +--------------- package.json | 2 +- .../automatic-languages-1024d07902ccbbd0.yaml | 5 + src/js/actions/I18nActions.js | 4 +- src/js/components/NavBar.js | 4 +- src/js/components/i18n/I18nDropdown.js | 6 +- src/js/components/i18n/I18nProvider.js | 25 +---- src/js/constants/i18n.js | 11 +++ src/js/plugins/i18n.js | 97 +++++++++++++++++++ src/js/services/utils.js | 11 +++ webpack.common.js | 4 + 15 files changed, 229 insertions(+), 120 deletions(-) create mode 100755 bin/run_lint.sh create mode 100755 bin/verify-languages.js create mode 100644 releasenotes/notes/automatic-languages-1024d07902ccbbd0.yaml create mode 100644 src/js/constants/i18n.js create mode 100644 src/js/plugins/i18n.js diff --git a/.gitignore b/.gitignore index 009af31a..dc9c0dc2 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ messages.pot i18n/extracted-messages* npm-debug.log docs/_build +src/js/components/i18n/messages.js # Files created by releasenotes build releasenotes/build diff --git a/bin/run_lint.sh b/bin/run_lint.sh new file mode 100755 index 00000000..37bd96df --- /dev/null +++ b/bin/run_lint.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# Copyright 2017 Red Hat Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +set -e + +eslint --max-warnings 0 src +prettier --single-quote --list-different 'src/**/*.js' +./bin/verify-languages.js diff --git a/bin/verify-languages.js b/bin/verify-languages.js new file mode 100755 index 00000000..50b836fe --- /dev/null +++ b/bin/verify-languages.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** + * Copyright 2017 Red Hat Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +const fs = require('fs'); +const babel = require('babel-core'); +const _ = require('lodash'); +const constants = './src/js/constants/i18n.js'; + +function getConstants() { + const transformed = babel.transformFileSync(constants).code; + const languageNames = eval(transformed).LANGUAGE_NAMES; + delete languageNames['en']; + return Object.keys(languageNames); +} + +function getLocaleFiles() { + return fs.readdirSync('i18n/locales').map(function(el) { + return el.split('.')[0]; + }); +} + +function showError(constants, locales) { + let diff, name; + + if (constants.length > locales.length) { + diff = _.difference(constants, locales); + name = 'Locales'; + } else { + diff = _.difference(locales, constants); + name = 'Constants'; + } + + console.log(name, 'is missing', diff.join(', ')); +} + +function main() { + const constants = getConstants(); + const localeFiles = getLocaleFiles(); + + if (constants.length !== localeFiles.length) { + showError(constants, localeFiles); + process.exit(1); + } + +} + +main(); diff --git a/config/tripleo_ui_config.js.sample b/config/tripleo_ui_config.js.sample index 7beab449..abe9cc81 100644 --- a/config/tripleo_ui_config.js.sample +++ b/config/tripleo_ui_config.js.sample @@ -16,17 +16,11 @@ window.tripleOUiConfig = { // 'zaqar_default_queue': 'tripleo', // Languages - // If you choose more than one language, a language switcher will appear in the navigation bar. - // Only 'en' (English) is enabled by default. - // 'languages': { - // 'de': 'German', - // 'en': 'English', - // 'es': 'Spanish', - // 'id': 'Indonesian', - // 'ja': 'Japanese', - // 'ko-KR': 'Korean', - // 'zh-CN': 'Simplified Chinese' - // }, + // + // By default, all available languages are enabled. Use this setting to + // disable certain languages. + // + // 'excludedLanguages': ['de', 'ja'], // Logging // 'loggers': ['console'] diff --git a/docs/translation.rst b/docs/translation.rst index 4163c1ef..c714ac5e 100644 --- a/docs/translation.rst +++ b/docs/translation.rst @@ -41,82 +41,5 @@ one JSON file per language (Japanese in this example): Adding a new language --------------------- -The languages are defined and activated in 2 places. Additionally, the -puppet-tripleo module also needs to be updated for users installing the -UI via the ``openstack undercloud install`` command. - -1. The ``I18nProvider`` component -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To add a new language, import the relevant locale data from the ``react-intl`` -package, as well as the JSON file which contains the translation. The new -language then needs to be added to the ``MESSAGES`` constant and the -constructor. Here's an example for Japanese ("ja"): - -.. code-block:: js - - // ./src/js/components/i18n/I18nProvider - - import ja from 'react-intl/locale-data/ja'; - import jaMessages from '../../../../i18n/locales/ja.json'; - - const MESSAGES = { - 'ja': jaMessages['ja'] - }; - - class I18nProvider extends React.Component { - constructor() { - super(); - addLocaleData([...ja]); - } - - -2. The ``tripleo-ui`` configuration file -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Finally, you can choose which languages are offered to the user by adding them -to the ``tripleo_ui_config.js`` file: - -.. code-block:: js - - // Languages - // If you choose more than one language, a language switcher - // will appear in the navigation bar. - 'languages': { - 'en': 'English', - 'ja': 'Japanese' - }, - - -The last step is useful if a language has not -- or only partially -- been -translated yet. In this case an incomplete language can be defined in the app as -part of a regular release, but will not show up in the selector by default. Once -the language translation has been completed it can more easily be backported -mid-release by updating only the corresponding JSON file. - -3. The puppet UI manifest -~~~~~~~~~~~~~~~~~~~~~~~~~ - -When deploying the UI as part of a normal undercloud install, the -configuration file is created and managed by the `puppet-tripleo`_ -module. The `manifest for the UI`_ must be modified in two places: - -.. code-block:: puppet - - # ./manifests/ui.pp - - # [*enabled_languages*] - # Which languages to show in the UI. - # An array. - # Defaults to ['en', 'ja'] - - [...] - - $enabled_languages = { - 'en' => 'English', - 'ja' => 'Japanese' - } - - -.. _puppet-tripleo: http://git.openstack.org/cgit/openstack/puppet-tripleo -.. _manifest for the UI: http://git.openstack.org/cgit/openstack/puppet-tripleo/tree/manifests/ui.pp +When a new language is added, we need to update the ``src/constants/i18n.js`` +file with the name and abbreviation of the language. diff --git a/package.json b/package.json index 2c81e65c..cbc15371 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "webpack-merge": "^4.1.0" }, "scripts": { - "lint": "eslint --max-warnings 0 src && prettier --single-quote --list-different 'src/**/*.js'", + "lint": "./bin/run_lint.sh", "prettier": "prettier --write --single-quote 'src/**/*.js'", "build": "webpack --env=prod --bail --progress", "build:dev": "webpack --env=dev --bail --progress", diff --git a/releasenotes/notes/automatic-languages-1024d07902ccbbd0.yaml b/releasenotes/notes/automatic-languages-1024d07902ccbbd0.yaml new file mode 100644 index 00000000..f3591574 --- /dev/null +++ b/releasenotes/notes/automatic-languages-1024d07902ccbbd0.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + Fixes `bug 1682452 `__ + Automatically enable all available languages diff --git a/src/js/actions/I18nActions.js b/src/js/actions/I18nActions.js index 6c6e41e9..2b342d6b 100644 --- a/src/js/actions/I18nActions.js +++ b/src/js/actions/I18nActions.js @@ -14,11 +14,11 @@ * under the License. */ -import { getAppConfig } from '../services/utils'; +import { getEnabledLanguages } from '../services/utils'; export default { detectLanguage(messages) { - const configLanguages = getAppConfig().languages; + const configLanguages = getEnabledLanguages(); let language; // If the configuration contains only one language and there // are messages for it, return it; diff --git a/src/js/components/NavBar.js b/src/js/components/NavBar.js index b09b0c61..e4cea77e 100644 --- a/src/js/components/NavBar.js +++ b/src/js/components/NavBar.js @@ -20,7 +20,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { getAppConfig } from '../services/utils'; +import { getEnabledLanguages } from '../services/utils'; import NavTab from './ui/NavTab'; import I18nDropdown from './i18n/I18nDropdown'; @@ -52,7 +52,7 @@ export default class NavBar extends React.Component { } _renderLanguageDropdown() { - const languages = getAppConfig().languages || {}; + const languages = getEnabledLanguages(); // Only include the I18nDropdown if there's more than one // language to choose from. diff --git a/src/js/components/i18n/I18nDropdown.js b/src/js/components/i18n/I18nDropdown.js index a7a2b80c..6a03af6a 100644 --- a/src/js/components/i18n/I18nDropdown.js +++ b/src/js/components/i18n/I18nDropdown.js @@ -22,9 +22,9 @@ import React from 'react'; import Dropdown from '../ui/dropdown/Dropdown'; import DropdownToggle from '../ui/dropdown/DropdownToggle'; import DropdownItem from '../ui/dropdown/DropdownItem'; -import { getAppConfig } from '../../services/utils'; +import { getEnabledLanguages } from '../../services/utils'; import I18nActions from '../../actions/I18nActions'; -import { MESSAGES } from './I18nProvider'; +import { MESSAGES } from './messages'; const messages = defineMessages({ language: { @@ -35,7 +35,7 @@ const messages = defineMessages({ class I18nDropdown extends React.Component { _renderDropdownItems() { - const configLanguages = getAppConfig().languages || {}; + const configLanguages = getEnabledLanguages(); const langList = Object.keys(configLanguages).sort( (a, b) => configLanguages[a] > configLanguages[b] ); diff --git a/src/js/components/i18n/I18nProvider.js b/src/js/components/i18n/I18nProvider.js index 58efa882..944ee995 100644 --- a/src/js/components/i18n/I18nProvider.js +++ b/src/js/components/i18n/I18nProvider.js @@ -18,36 +18,17 @@ import { addLocaleData, IntlProvider } from 'react-intl'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import React from 'react'; -import de from 'react-intl/locale-data/de'; -import es from 'react-intl/locale-data/es'; -import id from 'react-intl/locale-data/id'; -import ja from 'react-intl/locale-data/ja'; -import ko from 'react-intl/locale-data/ko'; -import zh from 'react-intl/locale-data/zh'; import I18nActions from '../../actions/I18nActions'; -import deMessages from '../../../../i18n/locales/de.json'; -import esMessages from '../../../../i18n/locales/es.json'; -import idMessages from '../../../../i18n/locales/id.json'; -import jaMessages from '../../../../i18n/locales/ja.json'; -import kokrMessages from '../../../../i18n/locales/ko-KR.json'; -import zhcnMessages from '../../../../i18n/locales/zh-CN.json'; +// NOTE(hpokorny): src/components/i18n/messages.js is generated by webpack on the fly +import { MESSAGES, LOCALE_DATA } from './messages'; import { getLanguage, getMessages } from '../../selectors/i18n'; -export const MESSAGES = { - de: deMessages['de'], - es: esMessages['es'], - id: idMessages['id'], - ja: jaMessages['ja'], - 'ko-KR': kokrMessages['ko-KR'], - 'zh-CN': zhcnMessages['zh-CN'] -}; - class I18nProvider extends React.Component { constructor() { super(); - addLocaleData([...de, ...es, ...id, ...ja, ...ko, ...zh]); + addLocaleData(LOCALE_DATA); } componentDidMount() { diff --git a/src/js/constants/i18n.js b/src/js/constants/i18n.js new file mode 100644 index 00000000..6db0769b --- /dev/null +++ b/src/js/constants/i18n.js @@ -0,0 +1,11 @@ +export const LANGUAGE_NAMES = { + en: 'English', + de: 'German', + 'en-GB': 'British English', + es: 'Spanish', + id: 'Indonesian', + ja: 'Japanese', + 'ko-KR': 'Korean', + 'tr-TR': 'Turkish', + 'zh-CN': 'Simplified Chinese' +}; diff --git a/src/js/plugins/i18n.js b/src/js/plugins/i18n.js new file mode 100644 index 00000000..6152f1d7 --- /dev/null +++ b/src/js/plugins/i18n.js @@ -0,0 +1,97 @@ +/* I18nPlugin for Webpack + * ====================== + * + * This plugin is used to automatically enable all available languages. It + * looks in the "localePath" configuration variable (which in our case is + * "i18n/locales"), and reads the files in that directory. It then generates a + * Javascript file with all the necessary imports and other useful objects. + * This file is placed in "src/js/components/i18n/messages.js". You can see a + * sample of this file below. + * + * import deMessages from '../../../../i18n/locales/de.json'; + * import esMessages from '../../../../i18n/locales/es.json'; + * + * import de from 'react-intl/locale-data/de'; + * import es from 'react-intl/locale-data/es'; + * + * export const MESSAGES = { + * 'de': deMessages['de'], + * 'es': esMessages['es'] + * }; + * + * + * export const LOCALE_DATA = [...de, ...es]; + */ + +var fs = require('fs'); + +var I18nPlugin = function(options) { + this.options = options; +}; + +I18nPlugin.prototype.apply = function(compiler) { + var files = fs.readdirSync(this.options.localePath); + var locales = files.map(function(file) { + return file.replace('.json', ''); + }); + + var localesLower = locales.map(function(locale) { + return locale.toLowerCase().replace('-', ''); + }); + + var localesSimple = locales.map(function(locale) { + return locale.split('-')[0]; + }); + + var messagesImport = localesSimple + .map(function(locale, index) { + var lower = localesLower[index]; + var full = locales[index]; + return ( + 'import ' + + lower + + "Messages from '../../../../i18n/locales/" + + full + + ".json';" + ); + }) + .join('\n'); + + var reactIntlImport = localesSimple + .map(function(locale, index) { + return ( + 'import ' + locale + " from 'react-intl/locale-data/" + locale + "';" + ); + }) + .join('\n'); + + var messages = locales + .map(function(locale, index) { + var lower = localesLower[index]; + return "'" + locale + "': " + lower + "Messages['" + locale + "']"; + }) + .join(',\n '); + + var messagesObj = 'export const MESSAGES = {\n ' + messages + '\n}'; + + var localeData = localesSimple + .map(function(locale) { + return '...' + locale; + }) + .join(', '); + + var localeDataObj = 'export const LOCALE_DATA = [' + localeData + '];'; + + var file = + messagesImport + + '\n\n' + + reactIntlImport + + '\n\n' + + messagesObj + + '\n\n' + + localeDataObj; + + fs.writeFileSync('src/js/components/i18n/messages.js', file); +}; + +module.exports = I18nPlugin; diff --git a/src/js/services/utils.js b/src/js/services/utils.js index 0e61a2f7..3978c6b9 100644 --- a/src/js/services/utils.js +++ b/src/js/services/utils.js @@ -16,6 +16,7 @@ import { Map, List } from 'immutable'; import store from '../store'; +import { LANGUAGE_NAMES } from '../constants/i18n'; /** * Returns the public url of an openstack API, @@ -61,3 +62,13 @@ export function getProjectId() { export function getAppConfig() { return window.tripleOUiConfig || {}; } + +export function getEnabledLanguages() { + const excludedLanguages = getAppConfig().excludedLanguages || []; + let configLanguages = Object.assign({}, LANGUAGE_NAMES); + excludedLanguages.map(language => { + delete configLanguages[language]; + }); + + return configLanguages; +} diff --git a/webpack.common.js b/webpack.common.js index a1220314..d0d1e2ad 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -1,5 +1,6 @@ require('es6-promise').polyfill(); // https://github.com/webpack/css-loader/issues/144 const HtmlWebpackPlugin = require('html-webpack-plugin'); +const I18nPlugin = require('./src/js/plugins/i18n'); module.exports = { entry: __dirname + '/src/js/index.js', @@ -12,6 +13,9 @@ module.exports = { plugins: [ new HtmlWebpackPlugin({ template: 'src/index.html' + }), + new I18nPlugin({ + localePath: 'i18n/locales' }) ], module: {