diff --git a/client/src/actions/entry/user.js b/client/src/actions/entry/user.js index fc18d3f5..0f1b5fcf 100755 --- a/client/src/actions/entry/user.js +++ b/client/src/actions/entry/user.js @@ -41,6 +41,13 @@ export const handleUserUpdate = (user) => ({ }, }); +export const updateCurrentUserLanguage = (language) => ({ + type: EntryActionTypes.CURRENT_USER_LANGUAGE_UPDATE, + payload: { + language, + }, +}); + export const updateUserEmail = (id, data) => ({ type: EntryActionTypes.USER_EMAIL_UPDATE, payload: { diff --git a/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx b/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx index da1777ce..4f39c76c 100644 --- a/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx +++ b/client/src/components/UserSettingsModal/AccountPane/AccountPane.jsx @@ -1,8 +1,9 @@ import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; -import { Button, Divider, Header, Tab } from 'semantic-ui-react'; +import { Button, Divider, Dropdown, Header, Tab } from 'semantic-ui-react'; +import locales from '../../../locales'; import AvatarEditPopup from './AvatarEditPopup'; import User from '../../User'; import UserInformationEdit from '../../UserInformationEdit'; @@ -20,12 +21,14 @@ const AccountPane = React.memo( avatarUrl, phone, organization, + language, isAvatarUpdating, usernameUpdateForm, emailUpdateForm, passwordUpdateForm, onUpdate, onAvatarUpdate, + onLanguageUpdate, onUsernameUpdate, onUsernameUpdateMessageDismiss, onEmailUpdate, @@ -41,6 +44,13 @@ const AccountPane = React.memo( }); }, [onUpdate]); + const handleLanguageChange = useCallback( + (_, { value }) => { + onLanguageUpdate(value === 'auto' ? null : value); // FIXME: hack + }, + [onLanguageUpdate], + ); + return ( + +
+ {t('common.language', { + context: 'title', + })} +
+
+ ({ + key: locale.language, + value: locale.language, + flag: locale.country, + text: locale.name, + })), + ]} + value={language || 'auto'} + onChange={handleLanguageChange} + />
{t('common.authentication', { @@ -129,6 +165,7 @@ AccountPane.propTypes = { avatarUrl: PropTypes.string, phone: PropTypes.string, organization: PropTypes.string, + language: PropTypes.string, isAvatarUpdating: PropTypes.bool.isRequired, /* eslint-disable react/forbid-prop-types */ usernameUpdateForm: PropTypes.object.isRequired, @@ -137,6 +174,7 @@ AccountPane.propTypes = { /* eslint-enable react/forbid-prop-types */ onUpdate: PropTypes.func.isRequired, onAvatarUpdate: PropTypes.func.isRequired, + onLanguageUpdate: PropTypes.func.isRequired, onUsernameUpdate: PropTypes.func.isRequired, onUsernameUpdateMessageDismiss: PropTypes.func.isRequired, onEmailUpdate: PropTypes.func.isRequired, @@ -150,6 +188,7 @@ AccountPane.defaultProps = { avatarUrl: undefined, phone: undefined, organization: undefined, + language: undefined, }; export default AccountPane; diff --git a/client/src/components/UserSettingsModal/UserSettingsModal.jsx b/client/src/components/UserSettingsModal/UserSettingsModal.jsx index 92e9e86c..34303776 100644 --- a/client/src/components/UserSettingsModal/UserSettingsModal.jsx +++ b/client/src/components/UserSettingsModal/UserSettingsModal.jsx @@ -14,6 +14,7 @@ const UserSettingsModal = React.memo( avatarUrl, phone, organization, + language, subscribeToOwnCards, isAvatarUpdating, usernameUpdateForm, @@ -21,6 +22,7 @@ const UserSettingsModal = React.memo( passwordUpdateForm, onUpdate, onAvatarUpdate, + onLanguageUpdate, onUsernameUpdate, onUsernameUpdateMessageDismiss, onEmailUpdate, @@ -44,12 +46,14 @@ const UserSettingsModal = React.memo( avatarUrl={avatarUrl} phone={phone} organization={organization} + language={language} isAvatarUpdating={isAvatarUpdating} usernameUpdateForm={usernameUpdateForm} emailUpdateForm={emailUpdateForm} passwordUpdateForm={passwordUpdateForm} onUpdate={onUpdate} onAvatarUpdate={onAvatarUpdate} + onLanguageUpdate={onLanguageUpdate} onUsernameUpdate={onUsernameUpdate} onUsernameUpdateMessageDismiss={onUsernameUpdateMessageDismiss} onEmailUpdate={onEmailUpdate} @@ -92,6 +96,7 @@ UserSettingsModal.propTypes = { avatarUrl: PropTypes.string, phone: PropTypes.string, organization: PropTypes.string, + language: PropTypes.string, subscribeToOwnCards: PropTypes.bool.isRequired, isAvatarUpdating: PropTypes.bool.isRequired, /* eslint-disable react/forbid-prop-types */ @@ -101,6 +106,7 @@ UserSettingsModal.propTypes = { /* eslint-enable react/forbid-prop-types */ onUpdate: PropTypes.func.isRequired, onAvatarUpdate: PropTypes.func.isRequired, + onLanguageUpdate: PropTypes.func.isRequired, onUsernameUpdate: PropTypes.func.isRequired, onUsernameUpdateMessageDismiss: PropTypes.func.isRequired, onEmailUpdate: PropTypes.func.isRequired, @@ -115,6 +121,7 @@ UserSettingsModal.defaultProps = { avatarUrl: undefined, phone: undefined, organization: undefined, + language: undefined, }; export default UserSettingsModal; diff --git a/client/src/constants/EntryActionTypes.js b/client/src/constants/EntryActionTypes.js index 61540832..2698a143 100755 --- a/client/src/constants/EntryActionTypes.js +++ b/client/src/constants/EntryActionTypes.js @@ -31,6 +31,7 @@ export default { USER_UPDATE: `${PREFIX}/USER_UPDATE`, CURRENT_USER_UPDATE: `${PREFIX}/CURRENT_USER_UPDATE`, USER_UPDATE_HANDLE: `${PREFIX}/USER_UPDATE_HANDLE`, + CURRENT_USER_LANGUAGE_UPDATE: `${PREFIX}/CURRENT_USER_LANGUAGE_UPDATE`, USER_EMAIL_UPDATE: `${PREFIX}/USER_EMAIL_UPDATE`, CURRENT_USER_EMAIL_UPDATE: `${PREFIX}/CURRENT_USER_EMAIL_UPDATE`, USER_EMAIL_UPDATE_ERROR_CLEAR: `${PREFIX}/USER_EMAIL_UPDATE_ERROR_CLEAR`, diff --git a/client/src/containers/UserSettingsModalContainer.js b/client/src/containers/UserSettingsModalContainer.js index b89e81c2..388d3623 100644 --- a/client/src/containers/UserSettingsModalContainer.js +++ b/client/src/containers/UserSettingsModalContainer.js @@ -10,6 +10,7 @@ import { updateCurrentUser, updateCurrentUserAvatar, updateCurrentUserEmail, + updateCurrentUserLanguage, updateCurrentUserPassword, updateCurrentUserUsername, } from '../actions/entry'; @@ -23,6 +24,7 @@ const mapStateToProps = (state) => { avatarUrl, phone, organization, + language, subscribeToOwnCards, isAvatarUpdating, emailUpdateForm, @@ -37,6 +39,7 @@ const mapStateToProps = (state) => { avatarUrl, phone, organization, + language, subscribeToOwnCards, isAvatarUpdating, emailUpdateForm, @@ -50,6 +53,7 @@ const mapDispatchToProps = (dispatch) => { onUpdate: updateCurrentUser, onAvatarUpdate: updateCurrentUserAvatar, + onLanguageUpdate: updateCurrentUserLanguage, onUsernameUpdate: updateCurrentUserUsername, onUsernameUpdateMessageDismiss: clearCurrentUserUsernameUpdateError, onEmailUpdate: updateCurrentUserEmail, diff --git a/client/src/i18n.js b/client/src/i18n.js index c945ba77..4f5dda56 100644 --- a/client/src/i18n.js +++ b/client/src/i18n.js @@ -1,11 +1,11 @@ import i18n from 'i18next'; -import languageDetector from 'i18next-browser-languagedetector'; +import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; import formatDate from 'date-fns/format'; import parseDate from 'date-fns/parse'; import { registerLocale, setDefaultLocale } from 'react-datepicker'; -import { embedLocales, languages } from './locales'; +import { embeddedLocales, languages } from './locales'; i18n.dateFns = { locales: {}, @@ -52,12 +52,12 @@ const parseDatePostProcessor = { }; i18n - .use(languageDetector) + .use(LanguageDetector) .use(formatDatePostProcessor) .use(parseDatePostProcessor) .use(initReactI18next) .init({ - resources: embedLocales, + resources: embeddedLocales, fallbackLng: 'en', supportedLngs: languages, load: 'languageOnly', @@ -74,7 +74,7 @@ i18n }, }, react: { - useSuspense: false, + useSuspense: true, }, debug: process.env.NODE_ENV !== 'production', }); @@ -95,4 +95,20 @@ i18n.loadCoreLocale = async (language = i18n.resolvedLanguage) => { }); }; +i18n.detectLanguage = () => { + const { + services: { languageDetector, languageUtils }, + } = i18n; + + localStorage.removeItem(languageDetector.options.lookupLocalStorage); + + const detectedLanguages = languageDetector.detect(); + + i18n.language = languageUtils.getBestMatchFromCodes(detectedLanguages); + i18n.languages = languageUtils.toResolveHierarchy(i18n.language); + + i18n.resolvedLanguage = undefined; + i18n.setResolvedLanguage(i18n.language); +}; + export default i18n; diff --git a/client/src/locales/cs/index.js b/client/src/locales/cs/index.js new file mode 100644 index 00000000..b172ade8 --- /dev/null +++ b/client/src/locales/cs/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'cs', + country: 'cz', + name: 'Čeština', + embeddedLocale: login, +}; diff --git a/client/src/locales/da/index.js b/client/src/locales/da/index.js new file mode 100644 index 00000000..9fcfa709 --- /dev/null +++ b/client/src/locales/da/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'da', + country: 'dk', + name: 'Dansk', + embeddedLocale: login, +}; diff --git a/client/src/locales/de/core.js b/client/src/locales/de/core.js index 0794d5c3..077b247a 100644 --- a/client/src/locales/de/core.js +++ b/client/src/locales/de/core.js @@ -72,6 +72,7 @@ export default { deleteTask_title: 'Aufgabe löschen', deleteUser_title: 'Benutzer löschen', description: 'Beschreibung', + detectAutomatically: 'Automatische Erkennung', dropFileToUpload: 'Datei ablegen, um hochzuladen', editAttachment_title: 'Anhang bearbieten', editAvatar_title: 'Avatar bearbeiten', @@ -97,6 +98,7 @@ export default { hours: 'Stunden', invalidCurrentPassword: 'Das aktuelle Passwort ist falsch', labels: 'Labels', + language: 'Sprache', leaveBoard_title: 'Board verlassen', leaveProject_title: 'Projekt verlassen', list: 'Listen', diff --git a/client/src/locales/de/index.js b/client/src/locales/de/index.js new file mode 100644 index 00000000..fc655946 --- /dev/null +++ b/client/src/locales/de/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'de', + country: 'de', + name: 'Deutsch', + embeddedLocale: login, +}; diff --git a/client/src/locales/en/core.js b/client/src/locales/en/core.js index a416fe22..402efcd7 100644 --- a/client/src/locales/en/core.js +++ b/client/src/locales/en/core.js @@ -64,6 +64,7 @@ export default { deleteTask_title: 'Delete Task', deleteUser_title: 'Delete User', description: 'Description', + detectAutomatically: 'Detect automatically', dropFileToUpload: 'Drop file to upload', editAttachment_title: 'Edit Attachment', editAvatar_title: 'Edit Avatar', @@ -90,6 +91,7 @@ export default { hours: 'Hours', invalidCurrentPassword: 'Invalid current password', labels: 'Labels', + language: 'Language', leaveBoard_title: 'Leave Board', leaveProject_title: 'Leave Project', list: 'List', diff --git a/client/src/locales/en/index.js b/client/src/locales/en/index.js new file mode 100644 index 00000000..6a398d30 --- /dev/null +++ b/client/src/locales/en/index.js @@ -0,0 +1,11 @@ +import merge from 'lodash/merge'; + +import login from './login'; +import core from './core'; + +export default { + language: 'en', + country: 'us', + name: 'English', + embeddedLocale: merge(login, core), +}; diff --git a/client/src/locales/es/index.js b/client/src/locales/es/index.js new file mode 100644 index 00000000..bda1fe81 --- /dev/null +++ b/client/src/locales/es/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'es', + country: 'es', + name: 'Español', + embeddedLocale: login, +}; diff --git a/client/src/locales/fr/index.js b/client/src/locales/fr/index.js new file mode 100644 index 00000000..945c1bc3 --- /dev/null +++ b/client/src/locales/fr/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'fr', + country: 'fr', + name: 'Français', + embeddedLocale: login, +}; diff --git a/client/src/locales/index.js b/client/src/locales/index.js index 1b740792..dbff151d 100644 --- a/client/src/locales/index.js +++ b/client/src/locales/index.js @@ -1,37 +1,27 @@ -import merge from 'lodash/merge'; -import fromPairs from 'lodash/fromPairs'; +import cs from './cs'; +import da from './da'; +import de from './de'; +import en from './en'; +import es from './es'; +import fr from './fr'; +import ja from './ja'; +import pl from './pl'; +import ru from './ru'; +import sk from './sk'; +import sv from './sv'; +import uz from './uz'; +import zh from './zh'; -import csLogin from './cs/login'; -import daLogin from './da/login'; -import deLogin from './de/login'; -import enLogin from './en/login'; -import enCore from './en/core'; -import esLogin from './es/login'; -import frLogin from './fr/login'; -import jaLogin from './ja/login'; -import plLogin from './pl/login'; -import ruLogin from './ru/login'; -import skLogin from './sk/login'; -import svLogin from './sv/login'; -import uzLogin from './uz/login'; -import zhLogin from './zh/login'; +const locales = [cs, da, de, en, es, fr, ja, pl, ru, sk, sv, uz, zh]; -const localePairs = [ - ['cs', csLogin], - ['da', daLogin], - ['de', deLogin], - ['en', merge(enLogin, enCore)], - ['es', esLogin], - ['fr', frLogin], - ['ja', jaLogin], - ['pl', plLogin], - ['ru', ruLogin], - ['sk', skLogin], - ['sv', svLogin], - ['uz', uzLogin], - ['zh', zhLogin], -]; +export default locales; -export const languages = localePairs.map((locale) => locale[0]); +export const languages = locales.map((locale) => locale.language); -export const embedLocales = fromPairs(localePairs); +export const embeddedLocales = locales.reduce( + (result, locale) => ({ + ...result, + [locale.language]: locale.embeddedLocale, + }), + {}, +); diff --git a/client/src/locales/ja/index.js b/client/src/locales/ja/index.js new file mode 100644 index 00000000..b33d7c46 --- /dev/null +++ b/client/src/locales/ja/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'ja', + country: 'jp', + name: '日本語', + embeddedLocale: login, +}; diff --git a/client/src/locales/pl/index.js b/client/src/locales/pl/index.js new file mode 100644 index 00000000..73a9a66b --- /dev/null +++ b/client/src/locales/pl/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'pl', + country: 'pl', + name: 'Polski', + embeddedLocale: login, +}; diff --git a/client/src/locales/ru/core.js b/client/src/locales/ru/core.js index 1f3fe0d0..00494ab5 100644 --- a/client/src/locales/ru/core.js +++ b/client/src/locales/ru/core.js @@ -62,6 +62,7 @@ export default { deleteTask: 'Удаление задачи', deleteUser: 'Удаление пользователя', description: 'Описание', + detectAutomatically: 'Определить автоматически', dropFileToUpload: 'Перетяните файл, чтобы загрузить', editAttachment: 'Изменение вложения', editAvatar: 'Изменение аватара', @@ -88,6 +89,7 @@ export default { hours: 'Часы', invalidCurrentPassword: 'Неверный текущий пароль', labels: 'Метки', + language: 'Язык', list: 'Список', listActions: 'Действия со списком', members: 'Участники', diff --git a/client/src/locales/ru/index.js b/client/src/locales/ru/index.js new file mode 100644 index 00000000..1ffb7e0c --- /dev/null +++ b/client/src/locales/ru/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'ru', + country: 'ru', + name: 'Русский', + embeddedLocale: login, +}; diff --git a/client/src/locales/sk/index.js b/client/src/locales/sk/index.js new file mode 100644 index 00000000..ad9c4046 --- /dev/null +++ b/client/src/locales/sk/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'sk', + country: 'sk', + name: 'Slovenčina', + embeddedLocale: login, +}; diff --git a/client/src/locales/sv/index.js b/client/src/locales/sv/index.js new file mode 100644 index 00000000..d3b043d0 --- /dev/null +++ b/client/src/locales/sv/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'sv', + country: 'se', + name: 'Svenska', + embeddedLocale: login, +}; diff --git a/client/src/locales/uz/index.js b/client/src/locales/uz/index.js new file mode 100644 index 00000000..6ecec3c6 --- /dev/null +++ b/client/src/locales/uz/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'uz', + country: 'uz', + name: "O'zbek", + embeddedLocale: login, +}; diff --git a/client/src/locales/zh/index.js b/client/src/locales/zh/index.js new file mode 100644 index 00000000..aa5c15eb --- /dev/null +++ b/client/src/locales/zh/index.js @@ -0,0 +1,8 @@ +import login from './login'; + +export default { + language: 'zh', + country: 'cn', + name: '中文', + embeddedLocale: login, +}; diff --git a/client/src/models/User.js b/client/src/models/User.js index 5840fcb6..168a3112 100755 --- a/client/src/models/User.js +++ b/client/src/models/User.js @@ -40,6 +40,7 @@ export default class extends Model { avatarUrl: attr(), phone: attr(), organization: attr(), + language: attr(), subscribeToOwnCards: attr(), deletedAt: attr(), isAdmin: attr({ diff --git a/client/src/sagas/core/services/core.js b/client/src/sagas/core/services/core.js index 563dfa41..ec3a496b 100644 --- a/client/src/sagas/core/services/core.js +++ b/client/src/sagas/core/services/core.js @@ -4,7 +4,6 @@ import { fetchCoreRequest } from '../requests'; import { initializeCore } from '../../../actions'; import i18n from '../../../i18n'; -// eslint-disable-next-line import/prefer-default-export export function* initializeCoreService() { const { user, @@ -25,6 +24,7 @@ export function* initializeCoreService() { notifications, } = yield call(fetchCoreRequest); // TODO: handle error + yield call(i18n.changeLanguage, user.language); yield call(i18n.loadCoreLocale); yield put( @@ -48,3 +48,14 @@ export function* initializeCoreService() { ), ); } + +export function* changeCoreLanguageService(language) { + if (language === null) { + yield call(i18n.detectLanguage); + yield call(i18n.loadCoreLocale); + yield call(i18n.changeLanguage, i18n.resolvedLanguage); + } else { + yield call(i18n.loadCoreLocale, language); + yield call(i18n.changeLanguage, language); + } +} diff --git a/client/src/sagas/core/services/user.js b/client/src/sagas/core/services/user.js index cea6d06a..6f9fc88e 100644 --- a/client/src/sagas/core/services/user.js +++ b/client/src/sagas/core/services/user.js @@ -1,6 +1,7 @@ import { call, put, select } from 'redux-saga/effects'; import { logoutService } from './login'; +import { changeCoreLanguageService } from './core'; import request from '../request'; import { currentUserIdSelector, currentUserSelector, pathSelector } from '../../../selectors'; import { @@ -81,6 +82,21 @@ export function* handleUserUpdateService(user) { yield put(handleUserUpdate(user, users, isCurrent)); } +// TODO: add loading state +export function* updateUserLanguageService(id, language) { + yield call(changeCoreLanguageService, language); + + yield call(updateUserService, id, { + language, + }); +} + +export function* updateCurrentUserLanguageService(language) { + const id = yield select(currentUserIdSelector); + + yield call(updateUserLanguageService, id, language); +} + export function* updateUserEmailService(id, data) { yield put(updateUserEmail(id, data)); diff --git a/client/src/sagas/core/watchers/user.js b/client/src/sagas/core/watchers/user.js index a7f23c9e..48589b91 100644 --- a/client/src/sagas/core/watchers/user.js +++ b/client/src/sagas/core/watchers/user.js @@ -24,6 +24,7 @@ import { updateUserService, updateCurrentUserAvatarService, updateCurrentUserEmailService, + updateCurrentUserLanguageService, updateCurrentUserPasswordService, updateCurrentUserService, updateCurrentUserUsernameService, @@ -49,6 +50,9 @@ export default function* userWatchers() { takeEvery(EntryActionTypes.USER_UPDATE_HANDLE, ({ payload: { user } }) => handleUserUpdateService(user), ), + takeEvery(EntryActionTypes.CURRENT_USER_LANGUAGE_UPDATE, ({ payload: { language } }) => + updateCurrentUserLanguageService(language), + ), takeEvery(EntryActionTypes.USER_EMAIL_UPDATE, ({ payload: { id, data } }) => updateUserEmailService(id, data), ), diff --git a/server/api/controllers/users/create.js b/server/api/controllers/users/create.js index 5b495814..e0dd6dfe 100755 --- a/server/api/controllers/users/create.js +++ b/server/api/controllers/users/create.js @@ -40,6 +40,11 @@ module.exports = { isNotEmptyString: true, allowNull: true, }, + language: { + type: 'string', + isNotEmptyString: true, + allowNull: true, + }, subscribeToOwnCards: { type: 'boolean', }, @@ -62,6 +67,7 @@ module.exports = { 'username', 'phone', 'organization', + 'language', 'subscribeToOwnCards', ]); diff --git a/server/api/controllers/users/update.js b/server/api/controllers/users/update.js index f3e998a7..10abb6c6 100755 --- a/server/api/controllers/users/update.js +++ b/server/api/controllers/users/update.js @@ -32,6 +32,11 @@ module.exports = { isNotEmptyString: true, allowNull: true, }, + language: { + type: 'string', + isNotEmptyString: true, + allowNull: true, + }, subscribeToOwnCards: { type: 'boolean', }, @@ -66,6 +71,7 @@ module.exports = { 'avatarUrl', 'phone', 'organization', + 'language', 'subscribeToOwnCards', ]); diff --git a/server/api/models/User.js b/server/api/models/User.js index 3d235e65..b545fbb4 100755 --- a/server/api/models/User.js +++ b/server/api/models/User.js @@ -53,6 +53,11 @@ module.exports = { isNotEmptyString: true, allowNull: true, }, + language: { + type: 'string', + isNotEmptyString: true, + allowNull: true, + }, subscribeToOwnCards: { type: 'boolean', defaultsTo: false, diff --git a/server/db/migrations/20220725150723_add_language_to_user_account_table.js b/server/db/migrations/20220725150723_add_language_to_user_account_table.js new file mode 100644 index 00000000..b048a3ab --- /dev/null +++ b/server/db/migrations/20220725150723_add_language_to_user_account_table.js @@ -0,0 +1,11 @@ +module.exports.up = async (knex) => + knex.schema.table('user_account', (table) => { + /* Columns */ + + table.text('language'); + }); + +module.exports.down = async (knex) => + knex.schema.table('user_account', (table) => { + table.dropColumn('language'); + });