From 28fd58eb950a6271b835a8840e293ad424da2f01 Mon Sep 17 00:00:00 2001 From: Nick Ma Date: Fri, 9 Mar 2018 22:07:43 -0500 Subject: [PATCH] React i18n (#836) * wip(react-i18n): setup linters for react ci, fix lint errors, update flow-typed, mount path for i18n libs absolute import. * feat(i18n): display storybook example of a dynamically changing I18n Component. * feat(i18n): complete the integration of React-i18n to components in Storybook. * refactor(react): make all components contributed adhere to i18n specifications. * fix(webpack): fix react-on-rails plugin not understanding resolve.modules in webpack. * fix(styles): fix up linting rules modification. * style(Dropdown): add explicit case when locale is null or undefined. * style(client): clean up styles for code climate #1 * fix(webpack): add glob module to automatically extract all register points in client, make client resolve js and jsx. * style(codeclimate): adjust javascript mass threshold for duplication. * chore(codeclimate): pass lint and adjust code climate js duplcation mass weights --- .codeclimate.yml | 5 +- circle.yml | 2 +- client/.eslintignore | 2 + client/.eslintrc | 15 +- client/.flowconfig | 1 + client/.gitignore | 3 +- client/.storybook/webpack.config.js | 7 +- .../momentDashboards/components/Chart.jsx | 1 - .../components/ChartControl.jsx | 2 +- .../shared/components/Dropdown/Dropdown.jsx | 44 ++- .../shared/components/Footer/Connect.jsx | 52 +-- .../shared/components/Footer/Footer.jsx | 83 +++-- .../shared/components/Footer/Footer.scss | 5 +- .../bundles/shared/components/Footer/Ifme.jsx | 38 ++- .../shared/components/Footer/Resources.jsx | 42 ++- .../app/bundles/shared/components/Input.jsx | 22 +- client/app/bundles/shared/components/Logo.jsx | 2 +- client/app/bundles/shared/components/Tag.jsx | 24 +- .../bundles/shared/components/Textarea.jsx | 20 +- .../bundles/shared/startup/registration.jsx | 7 +- client/app/libs/i18n/.gitkeep | 0 client/app/libs/i18n/I18nSetup.js | 26 ++ client/app/libs/i18n/I18nUtils.js | 12 + client/app/stories/index.jsx | 80 ++++- client/package.json | 14 +- client/webpack.config.base.js | 11 + client/webpack.config.js | 14 +- client/yarn.lock | 297 +++++++++++++----- config/initializers/react_on_rails.rb | 2 +- 29 files changed, 584 insertions(+), 249 deletions(-) create mode 100644 client/app/libs/i18n/.gitkeep create mode 100644 client/app/libs/i18n/I18nSetup.js create mode 100644 client/app/libs/i18n/I18nUtils.js create mode 100644 client/webpack.config.base.js diff --git a/.codeclimate.yml b/.codeclimate.yml index 3820fc454b..ec6b309670 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -10,8 +10,9 @@ engines: enabled: true config: languages: - - ruby - - javascript + ruby: {} + javascript: + mass_threshold: 80 exclude_paths: - "client/**/__tests__/" eslint: diff --git a/circle.yml b/circle.yml index 1d73e532bc..c713ed5ef2 100644 --- a/circle.yml +++ b/circle.yml @@ -24,7 +24,7 @@ test: - RAILS_ENV=test bundle exec rake db:create db:schema:load - cd client && RAILS_ENV=test bundle exec rake react_on_rails:locale && - yarn run build:test + yarn run lint:setup && yarn run lint && yarn run build:test override: - bundle exec rspec --format progress --format RspecJunitFormatter -o $CIRCLE_TEST_REPORTS/rspec.xml diff --git a/client/.eslintignore b/client/.eslintignore index 89179ade92..63e91d20ca 100644 --- a/client/.eslintignore +++ b/client/.eslintignore @@ -1,2 +1,4 @@ flow-typed/ karma.conf.js +translations.js +default.js diff --git a/client/.eslintrc b/client/.eslintrc index 2e385ed4a8..b871fea2de 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -16,6 +16,19 @@ "/^render.+$/", "render" ] - }] + }], + "react/prefer-stateless-function": ["off"] + }, + "settings": { + "import/resolver": { + "node": { + "paths": [ + // allow absolute import from "app/libs/.." as "libs/.." + "./app", + // allow absolute import from "../configs/locales.." as "configs/locales.." + ".." + ] + } + } } } diff --git a/client/.flowconfig b/client/.flowconfig index 784c4cf39d..c1bc79d437 100644 --- a/client/.flowconfig +++ b/client/.flowconfig @@ -12,3 +12,4 @@ [options] module.name_mapper='.*\(.s?css\)' -> 'empty/object' +module.name_mapper='^libs\(.*\)$' -> '/app/libs\1' diff --git a/client/.gitignore b/client/.gitignore index 1e485a11c7..9ea3112d42 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1,3 +1,4 @@ *.iml flow-typed/ - +translations.js +default.js diff --git a/client/.storybook/webpack.config.js b/client/.storybook/webpack.config.js index b065450244..e1595a42c8 100644 --- a/client/.storybook/webpack.config.js +++ b/client/.storybook/webpack.config.js @@ -1,6 +1,7 @@ -const path = require('path'); +const baseConfig = require('../webpack.config.base'); + +module.exports = Object.assign(baseConfig, { -module.exports = { module: { rules: [ { @@ -23,4 +24,4 @@ module.exports = { }, ], }, -}; +}); diff --git a/client/app/bundles/momentDashboards/components/Chart.jsx b/client/app/bundles/momentDashboards/components/Chart.jsx index 6adeefdcef..0d47369887 100644 --- a/client/app/bundles/momentDashboards/components/Chart.jsx +++ b/client/app/bundles/momentDashboards/components/Chart.jsx @@ -19,7 +19,6 @@ const colorSchemes = ['#6D0839', '#66118', '#7F503F', '#775577', '#CCAADD']; * We wrap the element here in case we want to replace ChartKick with another library. */ // We keep the class otherwise our enzyme tests can't reference this component by name -// eslint-disable-next-line react/prefer-stateless-function export default class Chart extends React.Component { props: chartShape; diff --git a/client/app/bundles/momentDashboards/components/ChartControl.jsx b/client/app/bundles/momentDashboards/components/ChartControl.jsx index 8ae4fcf527..fea478e47b 100644 --- a/client/app/bundles/momentDashboards/components/ChartControl.jsx +++ b/client/app/bundles/momentDashboards/components/ChartControl.jsx @@ -50,7 +50,7 @@ export default class ChartControl extends React.Component { - this.setState({type: value}); + this.setState({ type: value }); }; } diff --git a/client/app/bundles/shared/components/Dropdown/Dropdown.jsx b/client/app/bundles/shared/components/Dropdown/Dropdown.jsx index 1efd7af2e1..a77be3c33c 100644 --- a/client/app/bundles/shared/components/Dropdown/Dropdown.jsx +++ b/client/app/bundles/shared/components/Dropdown/Dropdown.jsx @@ -1,21 +1,39 @@ +// @flow import React from 'react'; import shortid from 'shortid'; +import isNil from 'lodash/isNil'; +import { getAvailableLocales } from 'libs/i18n/I18nUtils'; + import css from './Dropdown.scss'; -import enYML from '../../../../../../config/locales/en.yml'; +const defaultLocales = getAvailableLocales(); +type Props = { + onChange: (e: string) => void, + locale: string, + localeList: { [key: string]: string }, +}; -const options = Object.keys(enYML.en.languages).map(key => - (), -); +export default (variationClassName: string) => + ({ onChange, locale, localeList }: Props) => { + const localeOptions = isNil(localeList) ? defaultLocales : localeList; -export default variationClassName => () => ( -
- -
-); + const options = Object.keys(localeOptions).map(key => + (), + ); + return ( +
+ {!isNil(locale) ? ( + + ) : ( + + )} +
); + }; diff --git a/client/app/bundles/shared/components/Footer/Connect.jsx b/client/app/bundles/shared/components/Footer/Connect.jsx index b9fc4b696f..f4485f88fb 100644 --- a/client/app/bundles/shared/components/Footer/Connect.jsx +++ b/client/app/bundles/shared/components/Footer/Connect.jsx @@ -1,25 +1,39 @@ +// @flow import React from 'react'; -import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; +import { defaultMessages } from 'libs/i18n/default'; import css from './Footer.scss'; -const Connect = props => ( - -); - -Connect.propTypes = { - navigation: PropTypes.string.isRequired, - common: PropTypes.string.isRequired, +type Prop = { + intl: Object }; +const NEW_WINDOW_NAME = '_blank'; + +const links = [ + [defaultMessages.commonFormEmail, 'mailto:join.ifme@gmail.com'], + [defaultMessages.navigationFacebook, 'http://facebook.com/ifmeorg'], + [defaultMessages.navigationGithub, 'http://facebook.com/ifmeorg'], + [defaultMessages.navigationInstagram, 'https://www.instagram.com/ifmeorg'], + [defaultMessages.navigationMedium, 'https://medium.com/ifme'], + [defaultMessages.navigationOpencollective, 'https://opencollective.com/ifme'], + [defaultMessages.navigationPatreon, 'http://patreon.com/ifme'], + [defaultMessages.navigationRss, 'https://medium.com/feed/ifme'], + [defaultMessages.navigationTwitter, 'http://twitter.com/ifmeorg'], +]; + +const Connect = injectIntl(({ intl }: Prop) => { + const { formatMessage } = intl; + return ( + + ); +}); + export default Connect; diff --git a/client/app/bundles/shared/components/Footer/Footer.jsx b/client/app/bundles/shared/components/Footer/Footer.jsx index 53a83a8ba7..8213b95c62 100644 --- a/client/app/bundles/shared/components/Footer/Footer.jsx +++ b/client/app/bundles/shared/components/Footer/Footer.jsx @@ -1,43 +1,86 @@ +// @flow import React from 'react'; +import { IntlProvider, injectIntl } from 'react-intl'; +import { defaultMessages, defaultLocale } from 'libs/i18n/default'; +import { getMessages } from 'libs/i18n/I18nUtils'; import css from './Footer.scss'; import Resources from './Resources'; import Connect from './Connect'; import Ifme from './Ifme'; import DropdownGhostSmall from '../Dropdown/DropdownGhostSmall'; -import enYML from '../../../../../../config/locales/en.yml'; +const we = defaultMessages.sharedFooterLicenseWe; +const foss = defaultMessages.sharedFooterLicenseFoss; +const licenseSubtitle = defaultMessages.sharedFooterLicenseSubtitle; +const licenseName = defaultMessages.sharedFooterLicenceName; -const navProp = enYML.en.navigation; -const we = enYML.en.shared.footer.license_we; -const foss = enYML.en.shared.footer.license_foss; +type FooterProps = { + intl: Object, + onChange: (locale: string) => void, +} -const Footer = () => ( -
-
+const TableCell = (props: { children: any }) => (
{props.children}
); + +const InjectedFooter = injectIntl(({ intl, onChange }: FooterProps) => { + const { formatMessage } = intl; + return ( + -
-); + ); +}); + +type Props = { + locale: string +}; + +type State = { + locale: string +} + +export default class Footer extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + locale: props.locale || defaultLocale, + }; + } -export default Footer; + render() { + return ( + + this.setState({ locale: selected })} + /> + + ); + } +} diff --git a/client/app/bundles/shared/components/Footer/Footer.scss b/client/app/bundles/shared/components/Footer/Footer.scss index 170b87aa7f..cb26ba150e 100644 --- a/client/app/bundles/shared/components/Footer/Footer.scss +++ b/client/app/bundles/shared/components/Footer/Footer.scss @@ -4,10 +4,7 @@ width: 100%; background: rgba(0, 0, 0, 0.5); font-family: $font-family; - - .footer_content { - position: relative; - } + position: relative; .row { display: flex; diff --git a/client/app/bundles/shared/components/Footer/Ifme.jsx b/client/app/bundles/shared/components/Footer/Ifme.jsx index e5245e139b..1fccf95ec6 100644 --- a/client/app/bundles/shared/components/Footer/Ifme.jsx +++ b/client/app/bundles/shared/components/Footer/Ifme.jsx @@ -1,23 +1,27 @@ +// @flow import React from 'react'; -import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; +import { defaultMessages } from 'libs/i18n/default'; import css from './Footer.scss'; -const Ifme = props => ( - -); - -Ifme.propTypes = { - navigation: PropTypes.string.isRequired, - app_name: PropTypes.string.isRequired, +type Prop = { + intl: Object }; +const Ifme = injectIntl(({ intl }: Prop) => { + const { formatMessage } = intl; + return ( + + ); +}); + export default Ifme; diff --git a/client/app/bundles/shared/components/Footer/Resources.jsx b/client/app/bundles/shared/components/Footer/Resources.jsx index f1ea8ebf64..c351769d78 100644 --- a/client/app/bundles/shared/components/Footer/Resources.jsx +++ b/client/app/bundles/shared/components/Footer/Resources.jsx @@ -1,20 +1,34 @@ +// @flow import React from 'react'; -import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; +import { defaultMessages } from 'libs/i18n/default'; import css from './Footer.scss'; -const Resources = props => ( - -); - -Resources.propTypes = { - pages: PropTypes.string.isRequired, - navigation: PropTypes.string.isRequired, +type Prop = { + intl: Object }; +const Resources = injectIntl(({ intl }: Prop) => { + const { formatMessage } = intl; + return ( + + ); +}); + export default Resources; diff --git a/client/app/bundles/shared/components/Input.jsx b/client/app/bundles/shared/components/Input.jsx index 1d971f994e..e2072f43c3 100644 --- a/client/app/bundles/shared/components/Input.jsx +++ b/client/app/bundles/shared/components/Input.jsx @@ -1,6 +1,6 @@ -//@flow -import React from "react"; -import css from "./Input.scss"; +// @flow +import React from 'react'; +import css from './Input.scss'; type Props = { dark?: boolean, @@ -11,7 +11,6 @@ type Props = { value?: string | number, placeholder?: string, label?: string, - value?: string, readonly?: boolean, disabled?: boolean, required?: boolean, @@ -27,7 +26,7 @@ type State = { export default class Input extends React.Component { constructor(props: Props) { super(props); - this.state = { value: this.props.value || "", active: false }; + this.state = { value: this.props.value || '', active: false }; } onChange = (e: SyntheticEvent) => { @@ -51,20 +50,19 @@ export default class Input extends React.Component { id, type, name, - value, placeholder, readonly, disabled, required, minLength, - maxLength + maxLength, } = this.props; - const labelClassNames = `${css.label} ${dark ? css.dark : ""} - ${large ? css.large : ""} ${this.state.active ? css.active : ""}`; + const labelClassNames = `${css.label} ${dark ? css.dark : ''} + ${large ? css.large : ''} ${this.state.active ? css.active : ''}`; - const inputClassNames = `${css.input} ${dark ? css.dark : ""} - ${large ? css.large : ""}`; + const inputClassNames = `${css.input} ${dark ? css.dark : ''} + ${large ? css.large : ''}`; return (
@@ -76,7 +74,7 @@ export default class Input extends React.Component { name={name} value={this.state.value} placeholder={placeholder} - readonly={readonly} + readOnly={readonly} disabled={disabled} required={required} minLength={minLength} diff --git a/client/app/bundles/shared/components/Logo.jsx b/client/app/bundles/shared/components/Logo.jsx index 2d9667374e..78372402a7 100644 --- a/client/app/bundles/shared/components/Logo.jsx +++ b/client/app/bundles/shared/components/Logo.jsx @@ -14,7 +14,7 @@ export default class Logo extends React.Component { const containerClass = `${css.container} ${css[size] || ''} ${linkClass}`; return ( -
+
if me
diff --git a/client/app/bundles/shared/components/Tag.jsx b/client/app/bundles/shared/components/Tag.jsx index 366227dec2..d7fe185763 100644 --- a/client/app/bundles/shared/components/Tag.jsx +++ b/client/app/bundles/shared/components/Tag.jsx @@ -1,26 +1,22 @@ -//@flow -import React from "react"; -import css from "./Tag.scss"; +// @flow +import React from 'react'; +import css from './Tag.scss'; type Props = { dark?: boolean, normal?: boolean, label?: string, - id?: string, }; export default class Tag extends React.Component { - constructor(props: Props) { - super(props); - } render() { - const {dark,normal,label,id} = this.props; - const labelClassNames = `${css.label} ${dark ? css.dark : ""}${normal ? css.normal : ""}`; + const { dark, normal, label } = this.props; + const labelClassNames = `${css.label} ${dark ? css.dark : ''}${normal ? css.normal : ''}`; - return ( - -
{label}
-
- ); + return ( + +
{label}
+
+ ); } } diff --git a/client/app/bundles/shared/components/Textarea.jsx b/client/app/bundles/shared/components/Textarea.jsx index 4d60a8a4ea..5dc502b1ed 100644 --- a/client/app/bundles/shared/components/Textarea.jsx +++ b/client/app/bundles/shared/components/Textarea.jsx @@ -1,6 +1,6 @@ -//@flow -import React from "react"; -import css from "./Textarea.scss"; +// @flow +import React from 'react'; +import css from './Textarea.scss'; type Props = { id?: string, @@ -11,7 +11,6 @@ type Props = { cols?: string | number, placeholder?: string, label?: string, - autofocus?: boolean, readonly?: boolean, disabled?: boolean, required?: boolean, @@ -26,7 +25,7 @@ type State = { export default class Input extends React.Component { constructor(props: Props) { super(props); - this.state = { value: "", active: false }; + this.state = { value: props.value ? props.value : '', active: false }; } onChange = (e: SyntheticEvent) => { @@ -47,20 +46,18 @@ export default class Input extends React.Component { id, form, name, - value, rows, cols, placeholder, label, - autofocus, readonly, disabled, required, - maxLength + maxLength, } = this.props; const labelClassNames = `${css.label} ${ - this.state.active ? css.active : "" + this.state.active ? css.active : '' }`; return ( @@ -70,14 +67,13 @@ export default class Input extends React.Component { className={css.textarea} id={id} name={name} - value={value} + value={this.state.value} form={form} rows={rows} cols={cols} placeholder={placeholder} label={label} - value={value} - readonly={readonly} + readOnly={readonly} disabled={disabled} required={required} maxLength={maxLength} diff --git a/client/app/bundles/shared/startup/registration.jsx b/client/app/bundles/shared/startup/registration.jsx index 552d548d01..ef3781bbeb 100644 --- a/client/app/bundles/shared/startup/registration.jsx +++ b/client/app/bundles/shared/startup/registration.jsx @@ -1,13 +1,16 @@ // @flow import ReactOnRails from 'react-on-rails'; +import { loadLocales } from 'libs/i18n/I18nSetup'; import Logo from '../components/Logo'; import Input from '../components/Input'; -import Textarea from "../components/Textarea"; +import Textarea from '../components/Textarea'; + +loadLocales(); // This is how react_on_rails can see the Components in the browser. ReactOnRails.register({ Logo, Input, - Textarea + Textarea, }); diff --git a/client/app/libs/i18n/.gitkeep b/client/app/libs/i18n/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/app/libs/i18n/I18nSetup.js b/client/app/libs/i18n/I18nSetup.js new file mode 100644 index 0000000000..5bf6e9348c --- /dev/null +++ b/client/app/libs/i18n/I18nSetup.js @@ -0,0 +1,26 @@ +import first from 'lodash/first'; +import { addLocaleData } from 'react-intl'; +// every locale is an array of various sub locales, that follow the type defined below +// https://github.com/yahoo/react-intl/wiki/API#addlocaledata +/* + type LocaleData = { + locale: string, + [key: string]: any, + } + */ +import en from 'react-intl/locale-data/en'; +import es from 'react-intl/locale-data/es'; +import it from 'react-intl/locale-data/it'; +import nb from 'react-intl/locale-data/nb'; +import sv from 'react-intl/locale-data/sv'; +import nl from 'react-intl/locale-data/nl'; +import br from 'react-intl/locale-data/br'; + +const ptbr = first(br); +ptbr.locale = 'ptbr'; + +// Initizalize all locales for react-intl. +// eslint-disable-next-line import/prefer-default-export +export const loadLocales = () => { + addLocaleData([...en, ...es, ...it, ...nb, ...sv, ...nl, ptbr]); +}; diff --git a/client/app/libs/i18n/I18nUtils.js b/client/app/libs/i18n/I18nUtils.js new file mode 100644 index 0000000000..c4fabcf4e1 --- /dev/null +++ b/client/app/libs/i18n/I18nUtils.js @@ -0,0 +1,12 @@ +import Cookies from 'js-cookie'; + +import enYML from 'config/locales/en.yml'; + +import { translations } from './translations'; +import { defaultLocale } from './default'; + +export const safeGetLocale = () => Cookies.get('locale') || defaultLocale; + +export const getMessages = locale => translations[locale]; + +export const getAvailableLocales = () => enYML.en.languages; diff --git a/client/app/stories/index.jsx b/client/app/stories/index.jsx index 2cc2bdcdc0..c1a52c72e8 100644 --- a/client/app/stories/index.jsx +++ b/client/app/stories/index.jsx @@ -1,14 +1,19 @@ import 'chartjs'; import React from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies import { storiesOf } from '@storybook/react'; +import { defaultMessages, defaultLocale } from 'libs/i18n/default'; +import { getMessages } from 'libs/i18n/I18nUtils'; +import { loadLocales } from 'libs/i18n/I18nSetup'; +import { IntlProvider, injectIntl } from 'react-intl'; import Chart from '../bundles/momentDashboards/components/Chart'; import ChartControl from '../bundles/momentDashboards/components/ChartControl'; import Logo from '../bundles/shared/components/Logo'; import Input from '../bundles/shared/components/Input'; -import Textarea from "../bundles/shared/components/Textarea"; +import Textarea from '../bundles/shared/components/Textarea'; import DropdownGhost from '../bundles/shared/components/Dropdown/DropdownGhost'; import DropdownGhostSmall from '../bundles/shared/components/Dropdown/DropdownGhostSmall'; import DropdownFillSmall from '../bundles/shared/components/Dropdown/DropdownFillSmall'; @@ -16,16 +21,18 @@ import Footer from '../bundles/shared/components/Footer/Footer'; import Tag from '../bundles/shared/components/Tag'; +loadLocales(); + storiesOf('Tags', module) .add('TagGhostXs', () => ( - - )) + + )) .add('TagDarkXs', () => ( - -)) + + )) .add('Tag', () => ( - -)); + + )); storiesOf('Logo', module) .add('Small', () => ( @@ -79,20 +86,67 @@ storiesOf('Input', module) )); +class I18nWrapper extends React.Component { + constructor(props) { + super(props); + this.state = { + locale: defaultLocale, + }; + } + + render() { + const TitleComponent = injectIntl(({ intl }) => ( +
+ {intl.formatMessage(defaultMessages.appDescription)} +
+ )); + + return ( + +
+ + this.setState({ locale: selectedLocale })} + locale={this.state.locale} + /> +
+
+ ); + } +} + storiesOf('Textarea', module) .add('Textarea', () => ( -