App config and I18n selectors refactor

* store appConfig in app store on application initialization
  so it is easily accessible
* move getEnabledLanguages from services/utils into i18n selectors
* do not duplicate messages in the state - the source is
  components/i18n/messages.js
* convert i18n components to receive current language and available
  languages from store using selectors

Change-Id: Id6fd0da41bb75caca721ea6082603501670c36b8
This commit is contained in:
Jiri Tomasek 2017-08-04 14:48:15 +02:00
parent 6c253c999d
commit d0a6bfddd2
16 changed files with 168 additions and 82 deletions

20
bin/run_tests.sh Executable file
View File

@ -0,0 +1,20 @@
#!/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
npm run build
jest

View File

@ -78,7 +78,8 @@
"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",
"start": "webpack-dev-server --env=dev --progress", "start": "webpack-dev-server --env=dev --progress",
"test": "jest", "test": "./bin/run_tests.sh",
"test:quick": "jest",
"test:watch": "jest --watchAll", "test:watch": "jest --watchAll",
"json2pot": "rip json2pot ./i18n/extracted-messages/**/*.json -o ./i18n/messages.pot", "json2pot": "rip json2pot ./i18n/extracted-messages/**/*.json -o ./i18n/messages.pot",
"po2json": "rip po2json -m ./i18n/extracted-messages/**/*.json", "po2json": "rip po2json -m ./i18n/extracted-messages/**/*.json",

View File

@ -14,38 +14,31 @@
* under the License. * under the License.
*/ */
import { getEnabledLanguages } from '../services/utils'; import { getEnabledLanguages } from '../selectors/i18n';
import { MESSAGES } from '../components/i18n/messages';
export default { export default {
detectLanguage(messages) { detectLanguage() {
const configLanguages = getEnabledLanguages(); return (dispatch, getState) => {
let language; const languages = getEnabledLanguages(getState());
// If the configuration contains only one language and there let language;
// are messages for it, return it; // If the configuration contains only one language and there
if ( // are messages for it, return it;
configLanguages && if (languages && languages.length === 1 && MESSAGES[languages[0]]) {
configLanguages.length === 1 && language = languages[0];
messages[configLanguages[0]] } else {
) { const locale =
language = configLanguages[0]; localStorage.getItem('language') ||
} else { (navigator.languages && navigator.languages[0]) ||
const locale = navigator.language ||
localStorage.getItem('language') || navigator.userLanguage;
(navigator.languages && navigator.languages[0]) || // If the locale contains the country but we can't find
navigator.language || // messages for it then we only use the country part:
navigator.userLanguage; language = locale.match(/^[A-Za-z]+-[A-Za-z]+$/) && !MESSAGES[locale]
// If the locale contains the country but we can't find ? locale.split('-')[0]
// messages for it then we only use the country part: : locale;
language = locale.match(/^[A-Za-z]+-[A-Za-z]+$/) && !messages[locale]
? locale.split('-')[0]
: locale;
}
return {
type: 'DETECT_LANGUAGE',
payload: {
language,
messages
} }
dispatch(this.chooseLanguage(language));
}; };
}, },

View File

@ -24,6 +24,7 @@ import { Redirect, Route, Switch, withRouter } from 'react-router-dom';
import DebugScreen from './debug/DebugScreen'; import DebugScreen from './debug/DebugScreen';
import DeploymentPlan from './deployment_plan/DeploymentPlan'; import DeploymentPlan from './deployment_plan/DeploymentPlan';
import { getCurrentPlanName } from '../selectors/plans'; import { getCurrentPlanName } from '../selectors/plans';
import { getEnabledLanguages } from '../selectors/i18n';
import Loader from './ui/Loader'; import Loader from './ui/Loader';
import LoginActions from '../actions/LoginActions'; import LoginActions from '../actions/LoginActions';
import NavBar from './NavBar'; import NavBar from './NavBar';
@ -49,7 +50,14 @@ class AuthenticatedContent extends React.Component {
} }
render() { render() {
const { currentPlanName, intl, logoutUser, plansLoaded, user } = this.props; const {
currentPlanName,
intl,
languages,
logoutUser,
plansLoaded,
user
} = this.props;
return ( return (
<Loader <Loader
loaded={plansLoaded} loaded={plansLoaded}
@ -57,7 +65,11 @@ class AuthenticatedContent extends React.Component {
global global
> >
<header> <header>
<NavBar user={user} onLogout={logoutUser.bind(this)} /> <NavBar
user={user}
onLogout={logoutUser.bind(this)}
languages={languages}
/>
</header> </header>
<div className="wrapper-fixed-body container-fluid"> <div className="wrapper-fixed-body container-fluid">
<div className="row"> <div className="row">
@ -87,6 +99,7 @@ AuthenticatedContent.propTypes = {
fetchWorkflowExecutions: PropTypes.func, fetchWorkflowExecutions: PropTypes.func,
initializeZaqarConnection: PropTypes.func.isRequired, initializeZaqarConnection: PropTypes.func.isRequired,
intl: PropTypes.object, intl: PropTypes.object,
languages: ImmutablePropTypes.map.isRequired,
logoutUser: PropTypes.func.isRequired, logoutUser: PropTypes.func.isRequired,
plansLoaded: PropTypes.bool, plansLoaded: PropTypes.bool,
user: ImmutablePropTypes.map user: ImmutablePropTypes.map
@ -102,6 +115,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
languages: getEnabledLanguages(state),
currentPlanName: getCurrentPlanName(state), currentPlanName: getCurrentPlanName(state),
plansLoaded: state.plans.get('plansLoaded'), plansLoaded: state.plans.get('plansLoaded'),
user: state.login.getIn(['token', 'user']) user: state.login.getIn(['token', 'user'])

View File

@ -20,7 +20,6 @@ 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 { getEnabledLanguages } from '../services/utils';
import NavTab from './ui/NavTab'; import NavTab from './ui/NavTab';
import I18nDropdown from './i18n/I18nDropdown'; import I18nDropdown from './i18n/I18nDropdown';
import StatusDropdown from './StatusDropdown'; import StatusDropdown from './StatusDropdown';
@ -57,11 +56,9 @@ export default class NavBar extends React.Component {
} }
_renderLanguageDropdown() { _renderLanguageDropdown() {
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.
return Object.keys(languages).length > 1 return this.props.languages.size > 1
? <li> ? <li>
<I18nDropdown /> <I18nDropdown />
</li> </li>
@ -135,6 +132,7 @@ export default class NavBar extends React.Component {
} }
} }
NavBar.propTypes = { NavBar.propTypes = {
languages: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired, onLogout: PropTypes.func.isRequired,
user: ImmutablePropTypes.map user: ImmutablePropTypes.map
}; };

View File

@ -16,15 +16,16 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; 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 { getEnabledLanguages, getCurrentLanguage } from '../../selectors/i18n';
import I18nActions from '../../actions/I18nActions'; import I18nActions from '../../actions/I18nActions';
import { MESSAGES } from './messages'; import { MESSAGES } from './messages';
import { getEnabledLanguages } from '../../services/utils';
const messages = defineMessages({ const messages = defineMessages({
language: { language: {
@ -35,10 +36,10 @@ const messages = defineMessages({
class I18nDropdown extends React.Component { class I18nDropdown extends React.Component {
_renderDropdownItems() { _renderDropdownItems() {
const enabledLang = this.props.language; const { currentLanguage, languages } = this.props;
return getEnabledLanguages() return languages
.map((langName, langKey) => { .map((langName, langKey) => {
const active = enabledLang === langKey; const active = currentLanguage === langKey;
return MESSAGES[langKey] || langKey === 'en' return MESSAGES[langKey] || langKey === 'en'
? <DropdownItem ? <DropdownItem
key={`lang-${langKey}`} key={`lang-${langKey}`}
@ -66,12 +67,14 @@ class I18nDropdown extends React.Component {
I18nDropdown.propTypes = { I18nDropdown.propTypes = {
chooseLanguage: PropTypes.func.isRequired, chooseLanguage: PropTypes.func.isRequired,
language: PropTypes.string currentLanguage: PropTypes.string,
languages: ImmutablePropTypes.map.isRequired
}; };
const mapStateToProps = state => { const mapStateToProps = state => {
return { return {
language: state.i18n.get('language', 'en') languages: getEnabledLanguages(state),
currentLanguage: getCurrentLanguage(state)
}; };
}; };

View File

@ -22,8 +22,11 @@ import React from 'react';
import I18nActions from '../../actions/I18nActions'; import I18nActions from '../../actions/I18nActions';
// NOTE(hpokorny): src/components/i18n/messages.js is generated by webpack on the fly // NOTE(hpokorny): src/components/i18n/messages.js is generated by webpack on the fly
import { MESSAGES, LOCALE_DATA } from './messages'; import { LOCALE_DATA } from './messages';
import { getLanguage, getMessages } from '../../selectors/i18n'; import {
getCurrentLanguage,
getCurrentLanguageMessages
} from '../../selectors/i18n';
class I18nProvider extends React.Component { class I18nProvider extends React.Component {
constructor() { constructor() {
@ -32,7 +35,7 @@ class I18nProvider extends React.Component {
} }
componentDidMount() { componentDidMount() {
this.props.detectLanguage(MESSAGES); this.props.detectLanguage();
} }
render() { render() {
@ -63,8 +66,8 @@ const mapDispatchToProps = dispatch => {
const mapStateToProps = state => { const mapStateToProps = state => {
return { return {
language: getLanguage(state), language: getCurrentLanguage(state),
messages: getMessages(state) messages: getCurrentLanguageMessages(state)
}; };
}; };

View File

@ -16,12 +16,12 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl } from 'react-intl'; import { injectIntl } from 'react-intl';
import { getEnabledLanguages } from '../../services/utils';
class LanguageInput extends React.Component { class LanguageInput extends React.Component {
_renderOptions() { _renderOptions() {
return getEnabledLanguages() return this.props.languages
.map((langName, langKey) => { .map((langName, langKey) => {
return ( return (
<option key={`lang-${langKey}`} value={langKey}> <option key={`lang-${langKey}`} value={langKey}>
@ -63,6 +63,7 @@ LanguageInput.propTypes = {
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
chooseLanguage: PropTypes.func.isRequired, chooseLanguage: PropTypes.func.isRequired,
language: PropTypes.string, language: PropTypes.string,
languages: ImmutablePropTypes.map.isRequired,
name: PropTypes.string.isRequired name: PropTypes.string.isRequired
}; };

View File

@ -24,6 +24,7 @@ import ReactDOM from 'react-dom';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import FormErrorList from '../ui/forms/FormErrorList'; import FormErrorList from '../ui/forms/FormErrorList';
import { getCurrentLanguage, getEnabledLanguages } from '../../selectors/i18n';
import I18nActions from '../../actions/I18nActions'; import I18nActions from '../../actions/I18nActions';
import Loader from '../ui/Loader'; import Loader from '../ui/Loader';
import LoginInput from '../ui/forms/LoginInput'; import LoginInput from '../ui/forms/LoginInput';
@ -148,6 +149,7 @@ class Login extends React.Component {
name="language" name="language"
chooseLanguage={this.props.chooseLanguage} chooseLanguage={this.props.chooseLanguage}
language={this.props.language} language={this.props.language}
languages={this.props.languages}
/> />
<LoginInput <LoginInput
name="username" name="username"
@ -205,6 +207,7 @@ Login.propTypes = {
isAuthenticated: PropTypes.bool.isRequired, isAuthenticated: PropTypes.bool.isRequired,
isAuthenticating: PropTypes.bool.isRequired, isAuthenticating: PropTypes.bool.isRequired,
language: PropTypes.string, language: PropTypes.string,
languages: ImmutablePropTypes.map.isRequired,
location: PropTypes.object, location: PropTypes.object,
userLoggedIn: PropTypes.bool.isRequired userLoggedIn: PropTypes.bool.isRequired
}; };
@ -215,7 +218,8 @@ function mapStateToProps(state) {
formFieldErrors: state.login.getIn(['loginForm', 'formFieldErrors']), formFieldErrors: state.login.getIn(['loginForm', 'formFieldErrors']),
isAuthenticated: state.login.isAuthenticated, isAuthenticated: state.login.isAuthenticated,
isAuthenticating: state.login.get('isAuthenticating'), isAuthenticating: state.login.get('isAuthenticating'),
language: state.i18n.get('language', 'en'), language: getCurrentLanguage(state),
languages: getEnabledLanguages(state),
userLoggedIn: state.login.hasIn(['keystoneAccess', 'user']) userLoggedIn: state.login.hasIn(['keystoneAccess', 'user'])
}; };
} }

View File

@ -0,0 +1,24 @@
/**
* 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.
*/
import { List, Record } from 'immutable';
export const AppConfig = Record({
zaqarDefaultQueue: 'tripleo',
zaqarLoggerQueue: 'tripleo-ui-logging',
excludedLanguages: List(),
loggers: List(['console', 'zaqar'])
});

View File

@ -0,0 +1,26 @@
/**
* 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.
*/
import { AppConfig } from '../immutableRecords/appConfig';
const initialState = new AppConfig();
export default function appConfig(state = initialState, action) {
switch (action.type) {
default:
return state;
}
}

View File

@ -17,6 +17,7 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form'; import { reducer as formReducer } from 'redux-form';
import appConfig from './appConfig';
import environmentConfigurationReducer from './environmentConfigurationReducer'; import environmentConfigurationReducer from './environmentConfigurationReducer';
import filtersReducer from './filtersReducer'; import filtersReducer from './filtersReducer';
import i18nReducer from './i18nReducer'; import i18nReducer from './i18nReducer';
@ -33,6 +34,7 @@ import validationsReducer from './validationsReducer';
import workflowExecutionsReducer from './workflowExecutionsReducer'; import workflowExecutionsReducer from './workflowExecutionsReducer';
const appReducer = combineReducers({ const appReducer = combineReducers({
appConfig,
environmentConfiguration: environmentConfigurationReducer, environmentConfiguration: environmentConfigurationReducer,
executions: workflowExecutionsReducer, executions: workflowExecutionsReducer,
filters: filtersReducer, filters: filtersReducer,

View File

@ -14,20 +14,14 @@
* under the License. * under the License.
*/ */
import { Map } from 'immutable'; import { Record } from 'immutable';
const initialState = Map({ const InitialState = Record({
language: 'en', language: 'en'
messages: {}
}); });
export default function i18nReducer(state = initialState, action) { export default function i18nReducer(state = new InitialState(), action) {
switch (action.type) { switch (action.type) {
case 'DETECT_LANGUAGE':
return state
.set('language', action.payload.language)
.set('messages', action.payload.messages);
case 'CHOOSE_LANGUAGE': case 'CHOOSE_LANGUAGE':
return state.set('language', action.payload); return state.set('language', action.payload);

View File

@ -16,29 +16,40 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { Map } from 'immutable';
const languageSelector = state => state.i18n.get('language'); import { LANGUAGE_NAMES } from '../constants/i18n';
// TODO(jtomasek): This should rather be in /constants
import { MESSAGES } from '../components/i18n/messages';
const messagesSelector = state => state.i18n.get('messages'); const getMessages = () => MESSAGES;
const getAvailableLanguages = () => LANGUAGE_NAMES;
export const getAppConfig = state => state.appConfig;
export const getCurrentLanguage = state => state.i18n.language;
export const getLanguage = createSelector( export const getCurrentLanguageMessages = createSelector(
[languageSelector], [getCurrentLanguage, getMessages],
language => language
);
export const getMessages = createSelector(
[languageSelector, messagesSelector],
(language, messages) => messages[language] (language, messages) => messages[language]
); );
export const getIntl = createSelector( export const getIntl = createSelector(
[languageSelector, messagesSelector], [getCurrentLanguage, getCurrentLanguageMessages],
(language, messages) => { (language, messages) => {
const intlProvider = new IntlProvider( const intlProvider = new IntlProvider(
{ locale: language, messages: messages[language] }, { locale: language, messages: messages },
{} {}
); );
const { intl } = intlProvider.getChildContext(); const { intl } = intlProvider.getChildContext();
return intl; return intl;
} }
); );
export const getEnabledLanguages = createSelector(
[getAppConfig, getAvailableLanguages],
(appConfig, languages) =>
// with immutablejs v 4.0.0 this can be replaced with
// Map(languages).deleteAll(appConfig.excludedLanguages).sort();
Map(languages)
.filterNot((language, key) => appConfig.excludedLanguages.includes(key))
.sort()
);

View File

@ -16,7 +16,6 @@
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,
@ -62,13 +61,3 @@ 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 Map(configLanguages).sort();
}

View File

@ -16,10 +16,12 @@
import { applyMiddleware, createStore } from 'redux'; import { applyMiddleware, createStore } from 'redux';
import cookie from 'react-cookie'; import cookie from 'react-cookie';
import { fromJS } from 'immutable';
import thunkMiddleware from 'redux-thunk'; import thunkMiddleware from 'redux-thunk';
import createLogger from 'redux-logger'; import createLogger from 'redux-logger';
import logger, { predicate } from './services/logging/LoggingService'; import logger, { predicate } from './services/logging/LoggingService';
import { AppConfig } from './immutableRecords/appConfig';
import appReducer from './reducers/appReducer'; import appReducer from './reducers/appReducer';
import { InitialPlanState } from './immutableRecords/plans'; import { InitialPlanState } from './immutableRecords/plans';
import { InitialLoginState } from './immutableRecords/login'; import { InitialLoginState } from './immutableRecords/login';
@ -27,6 +29,7 @@ import { getIntl } from './selectors/i18n';
const hydrateStore = () => { const hydrateStore = () => {
return { return {
appConfig: new AppConfig(window && fromJS(window.tripleOUiConfig)),
plans: new InitialPlanState({ plans: new InitialPlanState({
currentPlanName: getStoredPlanName() currentPlanName: getStoredPlanName()
}), }),