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

View File

@ -14,38 +14,31 @@
* under the License.
*/
import { getEnabledLanguages } from '../services/utils';
import { getEnabledLanguages } from '../selectors/i18n';
import { MESSAGES } from '../components/i18n/messages';
export default {
detectLanguage(messages) {
const configLanguages = getEnabledLanguages();
let language;
// If the configuration contains only one language and there
// are messages for it, return it;
if (
configLanguages &&
configLanguages.length === 1 &&
messages[configLanguages[0]]
) {
language = configLanguages[0];
} else {
const locale =
localStorage.getItem('language') ||
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
navigator.userLanguage;
// If the locale contains the country but we can't find
// messages for it then we only use the country part:
language = locale.match(/^[A-Za-z]+-[A-Za-z]+$/) && !messages[locale]
? locale.split('-')[0]
: locale;
}
return {
type: 'DETECT_LANGUAGE',
payload: {
language,
messages
detectLanguage() {
return (dispatch, getState) => {
const languages = getEnabledLanguages(getState());
let language;
// If the configuration contains only one language and there
// are messages for it, return it;
if (languages && languages.length === 1 && MESSAGES[languages[0]]) {
language = languages[0];
} else {
const locale =
localStorage.getItem('language') ||
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
navigator.userLanguage;
// If the locale contains the country but we can't find
// messages for it then we only use the country part:
language = locale.match(/^[A-Za-z]+-[A-Za-z]+$/) && !MESSAGES[locale]
? locale.split('-')[0]
: locale;
}
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 DeploymentPlan from './deployment_plan/DeploymentPlan';
import { getCurrentPlanName } from '../selectors/plans';
import { getEnabledLanguages } from '../selectors/i18n';
import Loader from './ui/Loader';
import LoginActions from '../actions/LoginActions';
import NavBar from './NavBar';
@ -49,7 +50,14 @@ class AuthenticatedContent extends React.Component {
}
render() {
const { currentPlanName, intl, logoutUser, plansLoaded, user } = this.props;
const {
currentPlanName,
intl,
languages,
logoutUser,
plansLoaded,
user
} = this.props;
return (
<Loader
loaded={plansLoaded}
@ -57,7 +65,11 @@ class AuthenticatedContent extends React.Component {
global
>
<header>
<NavBar user={user} onLogout={logoutUser.bind(this)} />
<NavBar
user={user}
onLogout={logoutUser.bind(this)}
languages={languages}
/>
</header>
<div className="wrapper-fixed-body container-fluid">
<div className="row">
@ -87,6 +99,7 @@ AuthenticatedContent.propTypes = {
fetchWorkflowExecutions: PropTypes.func,
initializeZaqarConnection: PropTypes.func.isRequired,
intl: PropTypes.object,
languages: ImmutablePropTypes.map.isRequired,
logoutUser: PropTypes.func.isRequired,
plansLoaded: PropTypes.bool,
user: ImmutablePropTypes.map
@ -102,6 +115,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
});
const mapStateToProps = state => ({
languages: getEnabledLanguages(state),
currentPlanName: getCurrentPlanName(state),
plansLoaded: state.plans.get('plansLoaded'),
user: state.login.getIn(['token', 'user'])

View File

@ -20,7 +20,6 @@ import React from 'react';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { getEnabledLanguages } from '../services/utils';
import NavTab from './ui/NavTab';
import I18nDropdown from './i18n/I18nDropdown';
import StatusDropdown from './StatusDropdown';
@ -57,11 +56,9 @@ export default class NavBar extends React.Component {
}
_renderLanguageDropdown() {
const languages = getEnabledLanguages();
// Only include the I18nDropdown if there's more than one
// language to choose from.
return Object.keys(languages).length > 1
return this.props.languages.size > 1
? <li>
<I18nDropdown />
</li>
@ -135,6 +132,7 @@ export default class NavBar extends React.Component {
}
}
NavBar.propTypes = {
languages: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
user: ImmutablePropTypes.map
};

View File

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

View File

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

View File

@ -24,6 +24,7 @@ import ReactDOM from 'react-dom';
import { Redirect } from 'react-router-dom';
import FormErrorList from '../ui/forms/FormErrorList';
import { getCurrentLanguage, getEnabledLanguages } from '../../selectors/i18n';
import I18nActions from '../../actions/I18nActions';
import Loader from '../ui/Loader';
import LoginInput from '../ui/forms/LoginInput';
@ -148,6 +149,7 @@ class Login extends React.Component {
name="language"
chooseLanguage={this.props.chooseLanguage}
language={this.props.language}
languages={this.props.languages}
/>
<LoginInput
name="username"
@ -205,6 +207,7 @@ Login.propTypes = {
isAuthenticated: PropTypes.bool.isRequired,
isAuthenticating: PropTypes.bool.isRequired,
language: PropTypes.string,
languages: ImmutablePropTypes.map.isRequired,
location: PropTypes.object,
userLoggedIn: PropTypes.bool.isRequired
};
@ -215,7 +218,8 @@ function mapStateToProps(state) {
formFieldErrors: state.login.getIn(['loginForm', 'formFieldErrors']),
isAuthenticated: state.login.isAuthenticated,
isAuthenticating: state.login.get('isAuthenticating'),
language: state.i18n.get('language', 'en'),
language: getCurrentLanguage(state),
languages: getEnabledLanguages(state),
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 { reducer as formReducer } from 'redux-form';
import appConfig from './appConfig';
import environmentConfigurationReducer from './environmentConfigurationReducer';
import filtersReducer from './filtersReducer';
import i18nReducer from './i18nReducer';
@ -33,6 +34,7 @@ import validationsReducer from './validationsReducer';
import workflowExecutionsReducer from './workflowExecutionsReducer';
const appReducer = combineReducers({
appConfig,
environmentConfiguration: environmentConfigurationReducer,
executions: workflowExecutionsReducer,
filters: filtersReducer,

View File

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

View File

@ -16,29 +16,40 @@
import { createSelector } from 'reselect';
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(
[languageSelector],
language => language
);
export const getMessages = createSelector(
[languageSelector, messagesSelector],
export const getCurrentLanguageMessages = createSelector(
[getCurrentLanguage, getMessages],
(language, messages) => messages[language]
);
export const getIntl = createSelector(
[languageSelector, messagesSelector],
[getCurrentLanguage, getCurrentLanguageMessages],
(language, messages) => {
const intlProvider = new IntlProvider(
{ locale: language, messages: messages[language] },
{ locale: language, messages: messages },
{}
);
const { intl } = intlProvider.getChildContext();
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 store from '../store';
import { LANGUAGE_NAMES } from '../constants/i18n';
/**
* Returns the public url of an openstack API,
@ -62,13 +61,3 @@ 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 Map(configLanguages).sort();
}

View File

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