diff --git a/__tests__/integration/mirador/language_switching.test.js b/__tests__/integration/mirador/language_switching.test.js new file mode 100644 index 0000000000000000000000000000000000000000..134ec4a3b57f4e060f6f2f87e1da7a452e48e5eb --- /dev/null +++ b/__tests__/integration/mirador/language_switching.test.js @@ -0,0 +1,24 @@ +describe('Language Switching', () => { + describe('Application Language', () => { + it('allows the user to switch the application language', async () => { + await page.goto('http://127.0.0.1:4488/__tests__/integration/mirador/'); + + await expect(page).toClick('#menuBtn'); + await expect(page).toMatchElement('ul[role="menu"]'); + await expect(page).toMatchElement('li p', { text: 'Language' }); + + await expect(page).not.toMatchElement('li', { text: 'Deutsch' }); + await expect(page).not.toMatchElement('li', { text: 'English' }); + await expect(page).toClick('li', { text: 'Language' }); + await expect(page).toMatchElement('li', { text: 'Deutsch' }); + await expect(page).toMatchElement('li', { text: 'English' }); + + await expect(page).toMatchElement('[aria-label="Toggle window sidebar"]'); + await expect(page).not.toMatchElement('[aria-label="Seitenleiste umschalten"]'); + await expect(page).toClick('li', { text: 'Deutsch' }); + await page.waitFor(1000); + await expect(page).not.toMatchElement('[aria-label="Toggle window sidebar"]'); + await expect(page).toMatchElement('[aria-label="Seitenleiste umschalten"]'); + }); + }); +}); diff --git a/__tests__/src/components/App.test.js b/__tests__/src/components/App.test.js index 0a84c0f4545125de88f3dc692967d122758ecd91..2a246c7a47d8116f84e6a8436968ebbcd6123fc6 100644 --- a/__tests__/src/components/App.test.js +++ b/__tests__/src/components/App.test.js @@ -13,6 +13,7 @@ import i18n from '../../../src/i18n'; function createWrapper(props) { return shallow( <App + language="en" isFullscreenEnabled={false} isWorkspaceControlPanelVisible setWorkspaceFullscreen={() => {}} @@ -80,4 +81,14 @@ describe('App', () => { expect(wrapper.find(Workspace).length).toBe(0); expect(wrapper.find(WorkspaceAdd).length).toBe(1); }); + + describe('componentDidUpdate()', () => { + it('changes the i18n language if the language prop has been updated', () => { + const wrapper = createWrapper(); + + expect(i18n.language).toEqual('en'); + wrapper.setProps({ language: 'de' }); + expect(i18n.language).toEqual('de'); + }); + }); }); diff --git a/__tests__/src/components/LanguageSettings.test.js b/__tests__/src/components/LanguageSettings.test.js new file mode 100644 index 0000000000000000000000000000000000000000..8ae798ec89479cf348e61fa0a293d09b6ff7cd20 --- /dev/null +++ b/__tests__/src/components/LanguageSettings.test.js @@ -0,0 +1,84 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import LanguageSettings from '../../../src/components/LanguageSettings'; + +/** + * Helper function to create a shallow wrapper around LanguageSettings + */ +function createWrapper(props) { + return shallow( + <LanguageSettings + handleClick={() => {}} + languages={{}} + {...props} + />, + ); +} + +describe('LanguageSettings', () => { + let wrapper; + const languages = [ + { locale: 'de', label: 'Deutsch', current: true }, + { locale: 'en', label: 'English', current: false }, + ]; + + + it('renders a list with a list item for each language passed in props', () => { + wrapper = createWrapper({ languages }); + + expect(wrapper.find('WithStyles(MenuItem)').length).toBe(2); + }); + + it('non-active list items are buttons (and active are not)', () => { + wrapper = createWrapper({ languages }); + + expect( + wrapper + .find('WithStyles(MenuItem)') + .first() // The German / active button + .prop('button'), + ).toBe(false); + + expect( + wrapper + .find('WithStyles(MenuItem)') + .last() // The English / non-active button + .prop('button'), + ).toBe(true); + }); + + it('renders the check icon when the active prop returns true', () => { + wrapper = createWrapper({ languages }); + + expect( + wrapper + .find('WithStyles(MenuItem)') + .first() + .find('WithStyles(ListItemIcon) pure(CheckSharpIcon)') + .length, + ).toBe(1); + }); + + it('renders the language value in an Typography element wrapped in a ListItemText', () => { + wrapper = createWrapper({ languages }); + + const firstListText = wrapper + .find('WithStyles(MenuItem)') + .first() + .find('WithStyles(ListItemText) WithStyles(Typography)') + .children() + .text(); + + expect(firstListText).toEqual('Deutsch'); + }); + + it('triggers the handleClick prop when clicking a list item', () => { + const mockHandleClick = jest.fn(); + wrapper = createWrapper({ languages, handleClick: mockHandleClick }); + + wrapper.find('WithStyles(MenuItem)').last().simulate('click'); + + expect(mockHandleClick).toHaveBeenCalledTimes(1); + expect(mockHandleClick).toHaveBeenCalledWith('en'); + }); +}); diff --git a/__tests__/src/components/NestedMenu.test.js b/__tests__/src/components/NestedMenu.test.js new file mode 100644 index 0000000000000000000000000000000000000000..422b947ecb2618fba15b951a88c75dce8ee3d18e --- /dev/null +++ b/__tests__/src/components/NestedMenu.test.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import NestedMenu from '../../../src/components/NestedMenu'; + +/** + * Helper function to wrap creating a NestedMenu component +*/ +function createWrapper(props) { + return shallow( + <NestedMenu + icon={<>GivenIcon</>} + label="GivenLabel" + {...props} + > + <>GivenChildren</> + </NestedMenu>, + ); +} + +describe('NestedMenu', () => { + let wrapper; + + it('renders the given icon wrapped in a MUI ListItemIcon', () => { + wrapper = createWrapper(); + + expect(wrapper.find('WithStyles(ListItemIcon)').children().text()).toEqual('GivenIcon'); + }); + + it('renders the given label wrapped in a MUI Typography', () => { + wrapper = createWrapper(); + + expect(wrapper.find('WithStyles(Typography)').children().text()).toEqual('GivenLabel'); + }); + + it('renders the given children wrapped in a MUI Collapse', () => { + wrapper = createWrapper(); + + expect(wrapper.find('WithStyles(Collapse)').children().text()).toEqual('GivenChildren'); + }); + + it('toggles the local nestedMenuIsOpen state when clicking the MenuItem', () => { + wrapper = createWrapper(); + + expect(wrapper.state().nestedMenuIsOpen).toBe(false); + wrapper.find('WithStyles(MenuItem)').simulate('click'); + expect(wrapper.state().nestedMenuIsOpen).toBe(true); + wrapper.find('WithStyles(MenuItem)').simulate('click'); + expect(wrapper.state().nestedMenuIsOpen).toBe(false); + }); + + it('renders the appropriate expand/collapse icon based on the menu open state', () => { + wrapper = createWrapper(); + + expect(wrapper.state().nestedMenuIsOpen).toBe(false); + expect(wrapper.find('pure(ExpandMoreSharpIcon)').length).toBe(1); + expect(wrapper.find('purl(ExpandLessSharpIcon)').length).toBe(0); + wrapper.setState({ nestedMenuIsOpen: true }); + expect(wrapper.find('pure(ExpandMoreSharpIcon)').length).toBe(0); + expect(wrapper.find('pure(ExpandLessSharpIcon)').length).toBe(1); + }); + + it("the MUI Collapse component's in prop is based on the nestedMenuIsOpen state", () => { + wrapper = createWrapper(); + + expect(wrapper.state().nestedMenuIsOpen).toBe(false); + expect(wrapper.find('WithStyles(Collapse)').find({ in: false }).length).toBe(1); + expect(wrapper.find('WithStyles(Collapse)').find({ in: true }).length).toBe(0); + + wrapper.setState({ nestedMenuIsOpen: true }); + expect(wrapper.find('WithStyles(Collapse)').find({ in: true }).length).toBe(1); + expect(wrapper.find('WithStyles(Collapse)').find({ in: false }).length).toBe(0); + }); +}); diff --git a/__tests__/src/selectors/index.test.js b/__tests__/src/selectors/index.test.js index f3f04a75fe416ca6898e2b79dcceae4866b885fd..34e3abb4d77ae7a4bad28a1720936133bdf33b61 100644 --- a/__tests__/src/selectors/index.test.js +++ b/__tests__/src/selectors/index.test.js @@ -7,6 +7,7 @@ import { getCanvasLabel, getCompanionWindowForPosition, getDestructuredMetadata, + getLanguagesFromConfigWithCurrent, getSelectedCanvas, getWindowManifest, getManifestLogo, @@ -347,3 +348,18 @@ describe('getCompanionWindowForPosition', () => { expect(received).toBeUndefined(); }); }); + +describe('getLanguagesFromConfigWithCurrent', () => { + it('returns an array of objects with locale, label, and current properties', () => { + const state = { + config: { language: 'epo', availableLanguages: { epo: 'Esparanto', tlh: 'Klingon' } }, + }; + + const expected = [ + { locale: 'epo', label: 'Esparanto', current: true }, + { locale: 'tlh', label: 'Klingon', current: false }, + ]; + + expect(getLanguagesFromConfigWithCurrent(state)).toEqual(expected); + }); +}); diff --git a/locales/de/translation.json b/locales/de/translation.json index 43f71407ffa4c1cd4a163f85d41ba5d193ddb668..48e789a857c4ada389ed5c596513cfb618828063 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -21,6 +21,7 @@ "downloadExportWorkspace": "Download/Export Arbeitsfläche", "fetchManifest": "Hinzufügen", "fullScreen": "Vollbild", + "language": "Sprache", "light": "Hell", "listAllOpenWindows": "Liste der geöffneten Fenster", "manifestError": "Die Ressource konnte nicht hinzugefügt werden:", @@ -45,8 +46,6 @@ "zoomReset": "Ansicht zurücksetzen", "hideZoomControls": "Zoom-Steuerung verbergen", "showZoomControls": "Zoom-Steuerung anzeigen", - "numItems": "{{number}} Elemente", - - + "numItems": "{{number}} Elemente" } } diff --git a/locales/en/translation.json b/locales/en/translation.json index 8dd85ecce50b36df10f904f7beb8adeca293366e..5320a8eaa8ba579b08632f6e8930d74cdb187b2b 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -22,6 +22,7 @@ "fetchManifest": "Add", "fullScreen": "Full Screen", "hideZoomControls": "Hide zoom controls", + "language": "Language", "light": "Light", "listAllOpenWindows": "List all open windows", "manifestError": "The resource cannot be added:", diff --git a/src/components/App.js b/src/components/App.js index cee00e4bc969f20504dce767615e67935a112927..55bb715b5d4aafb9b5fe21425f75cddc50579792 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -15,6 +15,33 @@ import i18n from '../i18n'; * @prop {Object} manifests */ class App extends Component { + /** */ + constructor(props) { + super(props); + + this.i18n = i18n; + } + + /** + * Set i18n language on component mount + */ + componentDidMount() { + const { language } = this.props; + + this.i18n.changeLanguage(language); + } + + /** + * Update the i18n language if it is changed + */ + componentDidUpdate(prevProps) { + const { language } = this.props; + + if (prevProps.language !== language) { + this.i18n.changeLanguage(language); + } + } + /** * render * @return {String} - HTML markup for the component @@ -26,12 +53,12 @@ class App extends Component { } = this.props; Object.keys(translations).forEach((lng) => { - i18n.addResourceBundle(lng, 'translation', translations[lng], true, true); + this.i18n.addResourceBundle(lng, 'translation', translations[lng], true, true); }); return ( <div className={classNames(classes.background, ns('app'))}> - <I18nextProvider i18n={i18n}> + <I18nextProvider i18n={this.i18n}> <MuiThemeProvider theme={createMuiTheme(theme)}> <Fullscreen enabled={isFullscreenEnabled} @@ -55,6 +82,7 @@ class App extends Component { } App.propTypes = { + language: PropTypes.string.isRequired, theme: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types translations: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types isFullscreenEnabled: PropTypes.bool, diff --git a/src/components/LanguageSettings.js b/src/components/LanguageSettings.js new file mode 100644 index 0000000000000000000000000000000000000000..ebe77d427f6a8803fec3b5e66a52635d4c034560 --- /dev/null +++ b/src/components/LanguageSettings.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import List from '@material-ui/core/List'; +import MenuItem from '@material-ui/core/MenuItem'; +import Typography from '@material-ui/core/Typography'; +import CheckIcon from '@material-ui/icons/CheckSharp'; +import PropTypes from 'prop-types'; + +/** + * LanguageSettings ~ the workspace sub menu to change the language + * of the application +*/ +export default class LanguageSettings extends Component { + /** + * Returns the rendered component + */ + render() { + const { + handleClick, languages, + } = this.props; + + return ( + <List> + { + languages.map(language => ( + <MenuItem + button={!language.current} + key={language.locale} + onClick={() => { handleClick(language.locale); }} + > + { + language.current + && <ListItemIcon><CheckIcon /></ListItemIcon> + } + <ListItemText inset> + <Typography variant="inherit"> + {language.label} + </Typography> + </ListItemText> + </MenuItem> + )) + } + </List> + ); + } +} + +LanguageSettings.propTypes = { + handleClick: PropTypes.func.isRequired, + languages: PropTypes.arrayOf( + PropTypes.shape({ + current: PropTypes.bool.isRequired, + label: PropTypes.string.isRequired, + locale: PropTypes.string.isRequired, + }), + ).isRequired, +}; diff --git a/src/components/NestedMenu.js b/src/components/NestedMenu.js new file mode 100644 index 0000000000000000000000000000000000000000..1ced10fde4ff8c247a0746ee8b00d105599fff46 --- /dev/null +++ b/src/components/NestedMenu.js @@ -0,0 +1,72 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Collapse from '@material-ui/core/Collapse'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import MenuItem from '@material-ui/core/MenuItem'; +import Typography from '@material-ui/core/Typography'; +import ExpandLess from '@material-ui/icons/ExpandLessSharp'; +import ExpandMore from '@material-ui/icons/ExpandMoreSharp'; + +/** + * NestedMenu ~ A presentation component to render a menu item and have + * it control the visibility of the MUI List passed in as the children +*/ +export default class NestedMenu extends Component { + /** + * constructor - + */ + constructor(props) { + super(props); + + this.state = { + nestedMenuIsOpen: false, + }; + + this.handleMenuClick = this.handleMenuClick.bind(this); + } + + /** + * handleMenuClick toggles the nestedMenuIsOpen state + */ + handleMenuClick() { + const { nestedMenuIsOpen } = this.state; + + this.setState({ + nestedMenuIsOpen: !nestedMenuIsOpen, + }); + } + + /** + * Returns the rendered component + */ + render() { + const { nestedMenuIsOpen } = this.state; + const { children, icon, label } = this.props; + return ( + <> + <MenuItem onClick={this.handleMenuClick}> + <ListItemIcon>{icon}</ListItemIcon> + {/* ListItemText adds left padding and we want this to line-up with menu items */} + <ListItemText style={{ paddingLeft: 0 }}> + <Typography varient="inherit">{label}</Typography> + </ListItemText> + { + nestedMenuIsOpen + ? <ExpandLess /> + : <ExpandMore /> + } + </MenuItem> + <Collapse in={nestedMenuIsOpen} timeout="auto" unmountOnExit> + {children} + </Collapse> + </> + ); + } +} + +NestedMenu.propTypes = { + children: PropTypes.element.isRequired, + icon: PropTypes.element.isRequired, + label: PropTypes.string.isRequired, +}; diff --git a/src/components/WorkspaceMenu.js b/src/components/WorkspaceMenu.js index de769d15a8923841ed7fce8a2495509423718f22..facbe4fbd247422b43d7a8d88acfed61df7f3456 100644 --- a/src/components/WorkspaceMenu.js +++ b/src/components/WorkspaceMenu.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import Menu from '@material-ui/core/Menu'; import Divider from '@material-ui/core/Divider'; +import LanguageIcon from '@material-ui/icons/Language'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import LoupeIcon from '@material-ui/icons/Loupe'; import MenuItem from '@material-ui/core/MenuItem'; @@ -9,6 +10,8 @@ import SaveAltIcon from '@material-ui/icons/SaveAlt'; import SettingsIcon from '@material-ui/icons/Settings'; import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline'; import PropTypes from 'prop-types'; +import LanguageSettings from '../containers/LanguageSettings'; +import NestedMenu from './NestedMenu'; import WindowList from '../containers/WindowList'; import WorkspaceSettings from '../containers/WorkspaceSettings'; import WorkspaceExport from '../containers/WorkspaceExport'; @@ -92,6 +95,7 @@ class WorkspaceMenu extends Component { </ListItemIcon> <Typography varient="inherit">{t('listAllOpenWindows')}</Typography> </MenuItem> + <Divider /> <MenuItem aria-haspopup="true" onClick={(e) => { this.handleZoomToggleClick(e); handleClose(e); }} @@ -104,6 +108,11 @@ class WorkspaceMenu extends Component { { showZoomControls ? t('hideZoomControls') : t('showZoomControls') } </Typography> </MenuItem> + + <NestedMenu icon={<LanguageIcon />} label={t('language')}> + <LanguageSettings afterSelect={handleClose} /> + </NestedMenu> + <Divider /> <MenuItem aria-haspopup="true" diff --git a/src/config/settings.js b/src/config/settings.js index df3444143db0c59f5de76cc5ebd4de0583ce6b24..f0bf81a995ccec574b25f759897917a37a897b43 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -10,6 +10,11 @@ export default { useNextVariants: true // set so that console deprecation warning is removed } }, + language: 'en', + availableLanguages: { // All the languages available in the language switcher + de: 'Deutsch', + en: 'English', + }, translations: { }, window: { diff --git a/src/containers/App.js b/src/containers/App.js index 87ebc50d90b2ab0009497f471251a179a086081b..8136706c3b6ca75be63d7fd6468645a351e12abf 100644 --- a/src/containers/App.js +++ b/src/containers/App.js @@ -11,6 +11,7 @@ import App from '../components/App'; */ const mapStateToProps = state => ( { + language: state.config.language, theme: state.config.theme, translations: state.config.translations, isFullscreenEnabled: state.workspace.isFullscreenEnabled, diff --git a/src/containers/LanguageSettings.js b/src/containers/LanguageSettings.js new file mode 100644 index 0000000000000000000000000000000000000000..ffd9c2147e607ed7aa7cc45a69a43ac734d4ea7d --- /dev/null +++ b/src/containers/LanguageSettings.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import * as actions from '../state/actions'; +import { getLanguagesFromConfigWithCurrent } from '../state/selectors'; +import LanguageSettings from '../components/LanguageSettings'; + +/** + * Map state to props for connect + */ +const mapStateToProps = state => ({ + languages: getLanguagesFromConfigWithCurrent(state), +}); + +/** + * Map action dispatches to props for connect + */ +const mapDispatchToProps = (dispatch, { afterSelect }) => ({ + handleClick: (language) => { + dispatch(actions.updateConfig({ language })); + + afterSelect && afterSelect(); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(LanguageSettings); diff --git a/src/i18n.js b/src/i18n.js index dac6958f05709d746f951f1f33fb68cbc2f62a6f..e6d0d2daa855e9a950205aa8474c66fb67662b72 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1,9 +1,12 @@ import i18n from 'i18next'; import { reactI18nextModule } from 'react-i18next'; +import de from '../locales/de/translation.json'; import en from '../locales/en/translation.json'; + // Load translations for each language const resources = { + de, en, }; diff --git a/src/state/selectors/index.js b/src/state/selectors/index.js index e7b4565c634581f94ddc3ff0caf2e9f251c2dacd..adcaee87f3dff77bac6c604cbc24f7c4896c58d7 100644 --- a/src/state/selectors/index.js +++ b/src/state/selectors/index.js @@ -201,3 +201,18 @@ export function getCompanionWindowForPosition(state, windowId, position) { export function getCompantionWindowIds(state, windowId) { return state.windows[windowId].companionWindowIds; } + +/** +* Return languages from config (in state) and indicate which is currently set +* @param {object} state +* @return {Array} [ {locale: 'de', label: 'Deutsch', current: true}, ... ] +*/ +export function getLanguagesFromConfigWithCurrent(state) { + const { availableLanguages, language } = state.config; + + return Object.keys(availableLanguages).map(key => ({ + locale: key, + label: availableLanguages[key], + current: key === language, + })); +}