From b989ca639744573e00dd9c31acae7797aa7a141c Mon Sep 17 00:00:00 2001 From: micahmills Date: Tue, 9 Sep 2025 11:57:36 +0300 Subject: [PATCH] feat: Agent language settings (#11222) # Pull Request Template ## Description This Pull Request will provide a language selector in the Profile Settings for each user, and allows them to change the UI language per agent, defaulting back to the account locale. Fixes # #678 This does PR addresses the Dashboard view but does not change the language of the agents emails ## Type of change Please delete options that are not relevant. - [X ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? 1. Go to an Agents Profile settings page 2. Select a language from the Language drop down 3. the UI will update to the new i18n locale 4. navigate through the UI to make sure the appropriate language is being used 5. Refresh the page to test that the locale persists 270 - [X] My code follows the style guidelines of this project - [X] I have performed a self-review of my code - [X] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [X] My changes generate no new warnings - [X] I have added tests that prove my fix is effective or that my feature works - [X] New and existing unit tests pass locally with my changes - [X] Any dependent changes have been merged and published in downstream modules Checklist:.724.2708 --------- Co-authored-by: Sojan Jose Co-authored-by: Pranav Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin Co-authored-by: Muhsin Keloth --- app/controllers/concerns/switch_locale.rb | 21 +++- app/javascript/dashboard/App.vue | 11 +- .../composables/spec/useFontSize.spec.js | 26 +++-- .../dashboard/composables/useFontSize.js | 4 +- .../dashboard/i18n/locale/en/settings.json | 7 ++ .../dashboard/settings/account/Index.vue | 23 ++-- .../dashboard/settings/profile/Index.vue | 8 ++ .../settings/profile/UserLanguageSelect.vue | 103 ++++++++++++++++++ .../dashboard/store/modules/accounts.js | 14 ++- .../modules/specs/account/getters.spec.js | 79 ++++++++++---- spec/models/concerns/switch_locale_spec.rb | 20 ++++ 11 files changed, 261 insertions(+), 55 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/profile/UserLanguageSelect.vue diff --git a/app/controllers/concerns/switch_locale.rb b/app/controllers/concerns/switch_locale.rb index a8ea8ae05..1221d7155 100644 --- a/app/controllers/concerns/switch_locale.rb +++ b/app/controllers/concerns/switch_locale.rb @@ -4,17 +4,28 @@ module SwitchLocale private def switch_locale(&) - # priority is for locale set in query string (mostly for widget/from js sdk) + # Priority is for locale set in query string (mostly for widget/from js sdk) locale ||= params[:locale] + # Use the user's locale if available + locale ||= locale_from_user + + # Use the locale from a custom domain if applicable locale ||= locale_from_custom_domain + # if locale is not set in account, let's use DEFAULT_LOCALE env variable locale ||= ENV.fetch('DEFAULT_LOCALE', nil) + set_locale(locale, &) end def switch_locale_using_account_locale(&) - locale = locale_from_account(@current_account) + # Get the locale from the user first + locale = locale_from_user + + # Fallback to the account's locale if the user's locale is not set + locale ||= locale_from_account(@current_account) + set_locale(locale, &) end @@ -32,6 +43,12 @@ module SwitchLocale @portal.default_locale end + def locale_from_user + return unless @user + + @user.ui_settings&.dig('locale') + end + def set_locale(locale, &) safe_locale = validate_and_get_locale(locale) # Ensure locale won't bleed into other requests diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 8da7e7476..0fcb8c9fe 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -19,6 +19,7 @@ import { verifyServiceWorkerExistence, } from './helper/pushHelper'; import ReconnectService from 'dashboard/helper/ReconnectService'; +import { useUISettings } from 'dashboard/composables/useUISettings'; export default { name: 'App', @@ -38,12 +39,14 @@ export default { const { accountId } = useAccount(); // Use the font size composable (it automatically sets up the watcher) const { currentFontSize } = useFontSize(); + const { uiSettings } = useUISettings(); return { router, store, currentAccountId: accountId, currentFontSize, + uiSettings, }; }, data() { @@ -88,7 +91,10 @@ export default { mounted() { this.initializeColorTheme(); this.listenToThemeChanges(); - this.setLocale(window.chatwootConfig.selectedLocale); + // If user locale is set, use it; otherwise use account locale + this.setLocale( + this.uiSettings?.locale || window.chatwootConfig.selectedLocale + ); }, unmounted() { if (this.reconnectService) { @@ -114,7 +120,8 @@ export default { const { locale, latest_chatwoot_version: latestChatwootVersion } = this.getAccount(this.currentAccountId); const { pubsub_token: pubsubToken } = this.currentUser || {}; - this.setLocale(locale); + // If user locale is set, use it; otherwise use account locale + this.setLocale(this.uiSettings?.locale || locale); this.latestChatwootVersion = latestChatwootVersion; vueActionCable.init(this.store, pubsubToken); this.reconnectService = new ReconnectService(this.store, this.router); diff --git a/app/javascript/dashboard/composables/spec/useFontSize.spec.js b/app/javascript/dashboard/composables/spec/useFontSize.spec.js index 52d22478f..28b927610 100644 --- a/app/javascript/dashboard/composables/spec/useFontSize.spec.js +++ b/app/javascript/dashboard/composables/spec/useFontSize.spec.js @@ -43,18 +43,22 @@ describe('useFontSize', () => { it('returns fontSizeOptions with correct structure', () => { const { fontSizeOptions } = useFontSize(); - expect(fontSizeOptions).toHaveLength(5); - expect(fontSizeOptions[0]).toHaveProperty('value'); - expect(fontSizeOptions[0]).toHaveProperty('label'); + expect(fontSizeOptions.value).toHaveLength(5); + expect(fontSizeOptions.value[0]).toHaveProperty('value'); + expect(fontSizeOptions.value[0]).toHaveProperty('label'); // Check specific options - expect(fontSizeOptions.find(option => option.value === '16px')).toEqual({ + expect( + fontSizeOptions.value.find(option => option.value === '16px') + ).toEqual({ value: '16px', label: 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT', }); - expect(fontSizeOptions.find(option => option.value === '14px')).toEqual({ + expect( + fontSizeOptions.value.find(option => option.value === '14px') + ).toEqual({ value: '14px', label: 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER', @@ -143,12 +147,12 @@ describe('useFontSize', () => { const { fontSizeOptions } = useFontSize(); // Check that translation is applied - expect(fontSizeOptions.find(option => option.value === '14px').label).toBe( - 'Smaller' - ); - expect(fontSizeOptions.find(option => option.value === '16px').label).toBe( - 'Default' - ); + expect( + fontSizeOptions.value.find(option => option.value === '14px').label + ).toBe('Smaller'); + expect( + fontSizeOptions.value.find(option => option.value === '16px').label + ).toBe('Default'); // Verify translation function was called with correct keys expect(mockTranslate).toHaveBeenCalledWith( diff --git a/app/javascript/dashboard/composables/useFontSize.js b/app/javascript/dashboard/composables/useFontSize.js index d7177a5fb..92d6f9e72 100644 --- a/app/javascript/dashboard/composables/useFontSize.js +++ b/app/javascript/dashboard/composables/useFontSize.js @@ -77,8 +77,8 @@ export const useFontSize = () => { * Font size options for select dropdown * @type {Array<{value: string, label: string}>} */ - const fontSizeOptions = FONT_SIZE_NAMES.map(name => - createFontSizeOption(t, name) + const fontSizeOptions = computed(() => + FONT_SIZE_NAMES.map(name => createFontSizeOption(t, name)) ); /** diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 5b2197a03..2e24bace4 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -51,6 +51,13 @@ "LARGER": "Larger", "EXTRA_LARGE": "Extra Large" } + }, + "LANGUAGE": { + "TITLE": "Preferred Language", + "NOTE": "Choose the language you want to use.", + "UPDATE_SUCCESS": "Your Language settings have been updated successfully", + "UPDATE_ERROR": "There is an error while updating the language settings, please try again", + "USE_ACCOUNT_DEFAULT": "Use account default" } }, "MESSAGE_SIGNATURE_SECTION": { diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index 69342858d..c6e9a1ebb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -7,7 +7,6 @@ import { useUISettings } from 'dashboard/composables/useUISettings'; import { useConfig } from 'dashboard/composables/useConfig'; import { useAccount } from 'dashboard/composables/useAccount'; import { FEATURE_FLAGS } from '../../../../featureFlags'; -import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import WithLabel from 'v3/components/Form/WithLabel.vue'; import NextInput from 'next/input/Input.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; @@ -33,12 +32,12 @@ export default { NextInput, }, setup() { - const { updateUISettings } = useUISettings(); + const { updateUISettings, uiSettings } = useUISettings(); const { enabledLanguages } = useConfig(); const { accountId } = useAccount(); const v$ = useVuelidate(); - return { updateUISettings, v$, enabledLanguages, accountId }; + return { updateUISettings, uiSettings, v$, enabledLanguages, accountId }; }, data() { return { @@ -112,7 +111,7 @@ export default { const { name, locale, id, domain, support_email, features } = this.getAccount(this.accountId); - this.$root.$i18n.locale = locale; + this.$root.$i18n.locale = this.uiSettings?.locale || locale; this.name = name; this.locale = locale; this.id = id; @@ -137,21 +136,19 @@ export default { domain: this.domain, support_email: this.supportEmail, }); - this.$root.$i18n.locale = this.locale; + // If user locale is set, update the locale with user locale + if (this.uiSettings?.locale) { + this.$root.$i18n.locale = this.uiSettings?.locale; + } else { + // If user locale is not set, update the locale with account locale + this.$root.$i18n.locale = this.locale; + } this.getAccount(this.id).locale = this.locale; - this.updateDirectionView(this.locale); useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS')); } catch (error) { useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR')); } }, - - updateDirectionView(locale) { - const isRTLSupported = getLanguageDirection(locale); - this.updateUISettings({ - rtl_view: isRTLSupported, - }); - }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index bbde7a1c0..ce0a480bb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -11,6 +11,7 @@ import UserProfilePicture from './UserProfilePicture.vue'; import UserBasicDetails from './UserBasicDetails.vue'; import MessageSignature from './MessageSignature.vue'; import FontSize from './FontSize.vue'; +import UserLanguageSelect from './UserLanguageSelect.vue'; import HotKeyCard from './HotKeyCard.vue'; import ChangePassword from './ChangePassword.vue'; import NotificationPreferences from './NotificationPreferences.vue'; @@ -28,6 +29,7 @@ export default { MessageSignature, FormSection, FontSize, + UserLanguageSelect, UserProfilePicture, Policy, UserBasicDetails, @@ -230,6 +232,12 @@ export default { " @change="updateFontSize" /> + +import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useAlert } from 'dashboard/composables'; +import { useConfig } from 'dashboard/composables/useConfig'; +import { useAccount } from 'dashboard/composables/useAccount'; +import { useUISettings } from 'dashboard/composables/useUISettings'; + +import FormSelect from 'v3/components/Form/Select.vue'; + +defineProps({ + label: { type: String, default: '' }, + description: { type: String, default: '' }, +}); + +const { t, locale } = useI18n(); +const { updateUISettings, uiSettings } = useUISettings(); +const { enabledLanguages } = useConfig(); +const { currentAccount } = useAccount(); + +const currentLanguage = computed(() => uiSettings.value?.locale ?? ''); + +const languageOptions = computed(() => [ + { + name: t( + 'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.USE_ACCOUNT_DEFAULT' + ), + iso_639_1_code: '', + }, + ...(enabledLanguages ?? []), +]); + +const updateLanguage = async languageCode => { + try { + if (!languageCode) { + // Clear preference to use account default + await updateUISettings({ locale: null }); + locale.value = currentAccount.value.locale; + useAlert( + t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS') + ); + return; + } + + const valid = (enabledLanguages || []).some( + l => l.iso_639_1_code === languageCode + ); + if (!valid) { + throw new Error(`Invalid language code: ${languageCode}`); + } + + await updateUISettings({ locale: languageCode }); + // Apply immediately if the user explicitly chose a preference + locale.value = languageCode; + + useAlert( + t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_SUCCESS') + ); + } catch (error) { + useAlert( + t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.UPDATE_ERROR') + ); + throw error; + } +}; + +const selectedValue = computed({ + get: () => currentLanguage.value, + set: value => { + updateLanguage(value); + }, +}); + + + diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js index 0d5fdc748..1eef59cfe 100644 --- a/app/javascript/dashboard/store/modules/accounts.js +++ b/app/javascript/dashboard/store/modules/accounts.js @@ -28,12 +28,16 @@ export const getters = { getUIFlags($state) { return $state.uiFlags; }, - isRTL: ($state, _, rootState) => { - const accountId = rootState.route?.params?.accountId; - if (!accountId) return false; + isRTL: ($state, _getters, rootState, rootGetters) => { + const accountId = Number(rootState.route?.params?.accountId); + const userLocale = rootGetters?.getUISettings?.locale; + const accountLocale = + accountId && findRecordById($state, accountId)?.locale; - const { locale } = findRecordById($state, Number(accountId)); - return locale ? getLanguageDirection(locale) : false; + // Prefer user locale; fallback to account locale + const effectiveLocale = userLocale ?? accountLocale; + + return effectiveLocale ? getLanguageDirection(effectiveLocale) : false; }, isTrialAccount: $state => id => { const account = findRecordById($state, id); diff --git a/app/javascript/dashboard/store/modules/specs/account/getters.spec.js b/app/javascript/dashboard/store/modules/specs/account/getters.spec.js index 77cc9b357..f354faca2 100644 --- a/app/javascript/dashboard/store/modules/specs/account/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/account/getters.spec.js @@ -49,35 +49,74 @@ describe('#getters', () => { }); describe('isRTL', () => { - it('returns false when accountId is not present', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false when accountId is not present and userLocale is not set', () => { + const state = { records: [accountData] }; const rootState = { route: { params: {} } }; - expect(getters.isRTL({}, null, rootState)).toBe(false); + const rootGetters = {}; + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false); }); - it('returns true for RTL language', () => { - const state = { - records: [{ id: 1, locale: 'ar' }], - }; - const rootState = { route: { params: { accountId: '1' } } }; - vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(true); - expect(getters.isRTL(state, null, rootState)).toBe(true); + it('uses userLocale when present (no accountId)', () => { + const state = { records: [accountData] }; + const rootState = { route: { params: {} } }; + const rootGetters = { getUISettings: { locale: 'ar' } }; + const spy = vi + .spyOn(languageHelpers, 'getLanguageDirection') + .mockReturnValue(true); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true); + expect(spy).toHaveBeenCalledWith('ar'); }); - it('returns false for LTR language', () => { - const state = { - records: [{ id: 1, locale: 'en' }], - }; + it('prefers userLocale over account locale when both are present', () => { + const state = { records: [{ id: 1, locale: 'en' }] }; const rootState = { route: { params: { accountId: '1' } } }; - vi.spyOn(languageHelpers, 'getLanguageDirection').mockReturnValue(false); - expect(getters.isRTL(state, null, rootState)).toBe(false); + const rootGetters = { getUISettings: { locale: 'ar' } }; + const spy = vi + .spyOn(languageHelpers, 'getLanguageDirection') + .mockReturnValue(true); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true); + expect(spy).toHaveBeenCalledWith('ar'); }); - it('returns false when account is not found', () => { - const state = { - records: [], - }; + it('falls back to account locale when userLocale is not provided', () => { + const state = { records: [{ id: 1, locale: 'ar' }] }; const rootState = { route: { params: { accountId: '1' } } }; - expect(getters.isRTL(state, null, rootState)).toBe(false); + const rootGetters = {}; + const spy = vi + .spyOn(languageHelpers, 'getLanguageDirection') + .mockReturnValue(true); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true); + expect(spy).toHaveBeenCalledWith('ar'); + }); + + it('returns false for LTR language when userLocale is provided', () => { + const state = { records: [{ id: 1, locale: 'en' }] }; + const rootState = { route: { params: { accountId: '1' } } }; + const rootGetters = { getUISettings: { locale: 'en' } }; + const spy = vi + .spyOn(languageHelpers, 'getLanguageDirection') + .mockReturnValue(false); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false); + expect(spy).toHaveBeenCalledWith('en'); + }); + + it('returns false when accountId present but user locale is null', () => { + const state = { records: [{ id: 1, locale: 'en' }] }; + const rootState = { route: { params: { accountId: '1' } } }; + const rootGetters = { getUISettings: { locale: null } }; + const spy = vi.spyOn(languageHelpers, 'getLanguageDirection'); + + expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false); + expect(spy).toHaveBeenCalledWith('en'); }); }); }); diff --git a/spec/models/concerns/switch_locale_spec.rb b/spec/models/concerns/switch_locale_spec.rb index 98628b92c..421f254a0 100644 --- a/spec/models/concerns/switch_locale_spec.rb +++ b/spec/models/concerns/switch_locale_spec.rb @@ -29,6 +29,26 @@ RSpec.describe 'SwitchLocale Concern', type: :controller do end end + context 'when user has a locale set in ui_settings' do + let(:user) { create(:user, ui_settings: { 'locale' => 'es' }) } + + before { controller.instance_variable_set(:@user, user) } + + it 'returns the user locale' do + expect(controller.send(:locale_from_user)).to eq('es') + end + end + + context 'when user does not have a locale set' do + let(:user) { create(:user, ui_settings: {}) } + + before { controller.instance_variable_set(:@user, user) } + + it 'returns nil' do + expect(controller.send(:locale_from_user)).to be_nil + end + end + context 'when request is from custom domain' do before { request.host = portal.custom_domain }