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
This commit is contained in:
parent
c71308b90c
commit
5c9a687bb7
|
@ -13,6 +13,7 @@ messages.pot
|
||||||
i18n/extracted-messages*
|
i18n/extracted-messages*
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
docs/_build
|
docs/_build
|
||||||
|
src/js/components/i18n/messages.js
|
||||||
|
|
||||||
# Files created by releasenotes build
|
# Files created by releasenotes build
|
||||||
releasenotes/build
|
releasenotes/build
|
||||||
|
|
|
@ -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
|
|
@ -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();
|
|
@ -16,17 +16,11 @@ window.tripleOUiConfig = {
|
||||||
// 'zaqar_default_queue': 'tripleo',
|
// 'zaqar_default_queue': 'tripleo',
|
||||||
|
|
||||||
// Languages
|
// Languages
|
||||||
// If you choose more than one language, a language switcher will appear in the navigation bar.
|
//
|
||||||
// Only 'en' (English) is enabled by default.
|
// By default, all available languages are enabled. Use this setting to
|
||||||
// 'languages': {
|
// disable certain languages.
|
||||||
// 'de': 'German',
|
//
|
||||||
// 'en': 'English',
|
// 'excludedLanguages': ['de', 'ja'],
|
||||||
// 'es': 'Spanish',
|
|
||||||
// 'id': 'Indonesian',
|
|
||||||
// 'ja': 'Japanese',
|
|
||||||
// 'ko-KR': 'Korean',
|
|
||||||
// 'zh-CN': 'Simplified Chinese'
|
|
||||||
// },
|
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
// 'loggers': ['console']
|
// 'loggers': ['console']
|
||||||
|
|
|
@ -41,82 +41,5 @@ one JSON file per language (Japanese in this example):
|
||||||
Adding a new language
|
Adding a new language
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
The languages are defined and activated in 2 places. Additionally, the
|
When a new language is added, we need to update the ``src/constants/i18n.js``
|
||||||
puppet-tripleo module also needs to be updated for users installing the
|
file with the name and abbreviation of the language.
|
||||||
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
|
|
||||||
|
|
|
@ -70,7 +70,7 @@
|
||||||
"webpack-merge": "^4.1.0"
|
"webpack-merge": "^4.1.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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'",
|
"prettier": "prettier --write --single-quote 'src/**/*.js'",
|
||||||
"build": "webpack --env=prod --bail --progress",
|
"build": "webpack --env=prod --bail --progress",
|
||||||
"build:dev": "webpack --env=dev --bail --progress",
|
"build:dev": "webpack --env=dev --bail --progress",
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
fixes:
|
||||||
|
- |
|
||||||
|
Fixes `bug 1682452 <https://launchpad.net/bugs/1682452>`__
|
||||||
|
Automatically enable all available languages
|
|
@ -14,11 +14,11 @@
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAppConfig } from '../services/utils';
|
import { getEnabledLanguages } from '../services/utils';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
detectLanguage(messages) {
|
detectLanguage(messages) {
|
||||||
const configLanguages = getAppConfig().languages;
|
const configLanguages = getEnabledLanguages();
|
||||||
let language;
|
let language;
|
||||||
// If the configuration contains only one language and there
|
// If the configuration contains only one language and there
|
||||||
// are messages for it, return it;
|
// are messages for it, return it;
|
||||||
|
|
|
@ -20,7 +20,7 @@ import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
|
||||||
import { getAppConfig } from '../services/utils';
|
import { getEnabledLanguages } from '../services/utils';
|
||||||
import NavTab from './ui/NavTab';
|
import NavTab from './ui/NavTab';
|
||||||
import I18nDropdown from './i18n/I18nDropdown';
|
import I18nDropdown from './i18n/I18nDropdown';
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ export default class NavBar extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderLanguageDropdown() {
|
_renderLanguageDropdown() {
|
||||||
const languages = getAppConfig().languages || {};
|
const languages = getEnabledLanguages();
|
||||||
|
|
||||||
// Only include the I18nDropdown if there's more than one
|
// Only include the I18nDropdown if there's more than one
|
||||||
// language to choose from.
|
// language to choose from.
|
||||||
|
|
|
@ -22,9 +22,9 @@ import React from 'react';
|
||||||
import Dropdown from '../ui/dropdown/Dropdown';
|
import Dropdown from '../ui/dropdown/Dropdown';
|
||||||
import DropdownToggle from '../ui/dropdown/DropdownToggle';
|
import DropdownToggle from '../ui/dropdown/DropdownToggle';
|
||||||
import DropdownItem from '../ui/dropdown/DropdownItem';
|
import DropdownItem from '../ui/dropdown/DropdownItem';
|
||||||
import { getAppConfig } from '../../services/utils';
|
import { getEnabledLanguages } from '../../services/utils';
|
||||||
import I18nActions from '../../actions/I18nActions';
|
import I18nActions from '../../actions/I18nActions';
|
||||||
import { MESSAGES } from './I18nProvider';
|
import { MESSAGES } from './messages';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
language: {
|
language: {
|
||||||
|
@ -35,7 +35,7 @@ const messages = defineMessages({
|
||||||
|
|
||||||
class I18nDropdown extends React.Component {
|
class I18nDropdown extends React.Component {
|
||||||
_renderDropdownItems() {
|
_renderDropdownItems() {
|
||||||
const configLanguages = getAppConfig().languages || {};
|
const configLanguages = getEnabledLanguages();
|
||||||
const langList = Object.keys(configLanguages).sort(
|
const langList = Object.keys(configLanguages).sort(
|
||||||
(a, b) => configLanguages[a] > configLanguages[b]
|
(a, b) => configLanguages[a] > configLanguages[b]
|
||||||
);
|
);
|
||||||
|
|
|
@ -18,36 +18,17 @@ import { addLocaleData, IntlProvider } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
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 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';
|
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 {
|
class I18nProvider extends React.Component {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
addLocaleData([...de, ...es, ...id, ...ja, ...ko, ...zh]);
|
addLocaleData(LOCALE_DATA);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
|
|
@ -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'
|
||||||
|
};
|
|
@ -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;
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
import { Map, List } from 'immutable';
|
import { Map, List } from 'immutable';
|
||||||
import store from '../store';
|
import store from '../store';
|
||||||
|
import { LANGUAGE_NAMES } from '../constants/i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the public url of an openstack API,
|
* Returns the public url of an openstack API,
|
||||||
|
@ -61,3 +62,13 @@ export function getProjectId() {
|
||||||
export function getAppConfig() {
|
export function getAppConfig() {
|
||||||
return window.tripleOUiConfig || {};
|
return window.tripleOUiConfig || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEnabledLanguages() {
|
||||||
|
const excludedLanguages = getAppConfig().excludedLanguages || [];
|
||||||
|
let configLanguages = Object.assign({}, LANGUAGE_NAMES);
|
||||||
|
excludedLanguages.map(language => {
|
||||||
|
delete configLanguages[language];
|
||||||
|
});
|
||||||
|
|
||||||
|
return configLanguages;
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
require('es6-promise').polyfill(); // https://github.com/webpack/css-loader/issues/144
|
require('es6-promise').polyfill(); // https://github.com/webpack/css-loader/issues/144
|
||||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
const I18nPlugin = require('./src/js/plugins/i18n');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: __dirname + '/src/js/index.js',
|
entry: __dirname + '/src/js/index.js',
|
||||||
|
@ -12,6 +13,9 @@ module.exports = {
|
||||||
plugins: [
|
plugins: [
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
template: 'src/index.html'
|
template: 'src/index.html'
|
||||||
|
}),
|
||||||
|
new I18nPlugin({
|
||||||
|
localePath: 'i18n/locales'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
module: {
|
module: {
|
||||||
|
|
Loading…
Reference in New Issue