Skip to content
Snippets Groups Projects
Unverified Commit 3866d9ea authored by Camille Villa's avatar Camille Villa Committed by GitHub
Browse files

Merge pull request #1954 from ProjectMirador/1876-i18n-locale-toggle

Add a workspace language switcher
parents 12eab6ea c00438d2
No related branches found
No related tags found
No related merge requests found
Showing
with 428 additions and 5 deletions
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"]');
});
});
});
......@@ -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');
});
});
});
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');
});
});
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);
});
});
......@@ -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);
});
});
......@@ -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"
}
}
......@@ -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:",
......
......@@ -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,
......
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,
};
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,
};
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"
......
......@@ -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: {
......
......@@ -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,
......
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);
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,
};
......
......@@ -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,
}));
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment