1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

feat: Add language selector

Closes #212
This commit is contained in:
Maksim Eltyshev 2022-07-26 12:26:42 +02:00
parent a1cb04ea8e
commit 1329da3fe5
31 changed files with 277 additions and 40 deletions

View file

@ -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: {

View file

@ -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 (
<Tab.Pane attached={false} className={styles.wrapper}>
<AvatarEditPopup
@ -60,6 +70,32 @@ const AccountPane = React.memo(
}}
onUpdate={onUpdate}
/>
<Divider horizontal section>
<Header as="h4">
{t('common.language', {
context: 'title',
})}
</Header>
</Divider>
<Dropdown
fluid
selection
options={[
{
key: 'auto',
value: 'auto',
text: t('common.detectAutomatically'),
},
...locales.map((locale) => ({
key: locale.language,
value: locale.language,
flag: locale.country,
text: locale.name,
})),
]}
value={language || 'auto'}
onChange={handleLanguageChange}
/>
<Divider horizontal section>
<Header as="h4">
{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;

View file

@ -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;

View file

@ -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`,

View file

@ -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,

View file

@ -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;

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'cs',
country: 'cz',
name: 'Čeština',
embeddedLocale: login,
};

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'da',
country: 'dk',
name: 'Dansk',
embeddedLocale: login,
};

View file

@ -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',

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'de',
country: 'de',
name: 'Deutsch',
embeddedLocale: login,
};

View file

@ -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',

View file

@ -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),
};

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'es',
country: 'es',
name: 'Español',
embeddedLocale: login,
};

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'fr',
country: 'fr',
name: 'Français',
embeddedLocale: login,
};

View file

@ -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,
}),
{},
);

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'ja',
country: 'jp',
name: '日本語',
embeddedLocale: login,
};

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'pl',
country: 'pl',
name: 'Polski',
embeddedLocale: login,
};

View file

@ -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: 'Участники',

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'ru',
country: 'ru',
name: 'Русский',
embeddedLocale: login,
};

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'sk',
country: 'sk',
name: 'Slovenčina',
embeddedLocale: login,
};

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'sv',
country: 'se',
name: 'Svenska',
embeddedLocale: login,
};

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'uz',
country: 'uz',
name: "O'zbek",
embeddedLocale: login,
};

View file

@ -0,0 +1,8 @@
import login from './login';
export default {
language: 'zh',
country: 'cn',
name: '中文',
embeddedLocale: login,
};

View file

@ -40,6 +40,7 @@ export default class extends Model {
avatarUrl: attr(),
phone: attr(),
organization: attr(),
language: attr(),
subscribeToOwnCards: attr(),
deletedAt: attr(),
isAdmin: attr({

View file

@ -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);
}
}

View file

@ -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));

View file

@ -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),
),

View file

@ -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',
]);

View file

@ -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',
]);

View file

@ -53,6 +53,11 @@ module.exports = {
isNotEmptyString: true,
allowNull: true,
},
language: {
type: 'string',
isNotEmptyString: true,
allowNull: true,
},
subscribeToOwnCards: {
type: 'boolean',
defaultsTo: false,

View file

@ -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');
});