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 }