fix: Rendering of translations based on the user's locale (#13211)
Previously, translations were generated and resolved purely based on the account locale. This caused issues in multi-team, multi-region setups where agents often work in different languages than the account default. For example, an account might be set to English, while an agent prefers Spanish. In this setup: - Translations were always created using the account locale. - Agents could not view content in their preferred language. - This did not scale well for global teams. There was also an issue with locale resolution during rendering, where the system would incorrectly default to the account locale even when a more appropriate locale should have been used. With this update, During rendering, the system first attempts to use the agent’s locale. If a translation for that locale does not exist, it falls back to the account locale. **How to test:** - Set agent locale to a specific language (e.g., zh_CN) and account language to en. - Translate a message. - Verify translated content displays correctly for the agent's selected locale - Do the same for another locale for agent. - With multiple translations on a message (e.g., zh_CN, es, ml), verify the UI shows the one matching agent's locale - Change agent locale and verify the displayed translation updates accordingly
This commit is contained in:
@@ -42,7 +42,10 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['retry']);
|
const emit = defineEmits(['retry']);
|
||||||
|
|
||||||
const allMessages = computed(() => {
|
const allMessages = computed(() => {
|
||||||
return useCamelCase(props.messages, { deep: true });
|
return useCamelCase(props.messages, {
|
||||||
|
deep: true,
|
||||||
|
stopPaths: ['content_attributes.translations'],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentChat = useMapGetter('getSelectedChat');
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
|||||||
@@ -1,39 +1,31 @@
|
|||||||
import { ref } from 'vue';
|
import { selectTranslation } from '../useTranslations';
|
||||||
import { useTranslations } from '../useTranslations';
|
|
||||||
|
|
||||||
describe('useTranslations', () => {
|
describe('selectTranslation', () => {
|
||||||
it('returns false and null when contentAttributes is null', () => {
|
it('returns null when translations is null', () => {
|
||||||
const contentAttributes = ref(null);
|
expect(selectTranslation(null, 'en', 'en')).toBeNull();
|
||||||
const { hasTranslations, translationContent } =
|
|
||||||
useTranslations(contentAttributes);
|
|
||||||
expect(hasTranslations.value).toBe(false);
|
|
||||||
expect(translationContent.value).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false and null when translations are missing', () => {
|
it('returns null when translations is empty', () => {
|
||||||
const contentAttributes = ref({});
|
expect(selectTranslation({}, 'en', 'en')).toBeNull();
|
||||||
const { hasTranslations, translationContent } =
|
|
||||||
useTranslations(contentAttributes);
|
|
||||||
expect(hasTranslations.value).toBe(false);
|
|
||||||
expect(translationContent.value).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false and null when translations is an empty object', () => {
|
it('returns first translation when no locale matches', () => {
|
||||||
const contentAttributes = ref({ translations: {} });
|
const translations = { en: 'Hello', es: 'Hola' };
|
||||||
const { hasTranslations, translationContent } =
|
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
|
||||||
useTranslations(contentAttributes);
|
|
||||||
expect(hasTranslations.value).toBe(false);
|
|
||||||
expect(translationContent.value).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true and correct translation content when translations exist', () => {
|
it('returns translation matching agent locale', () => {
|
||||||
const contentAttributes = ref({
|
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
|
||||||
translations: { en: 'Hello' },
|
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
|
||||||
});
|
});
|
||||||
const { hasTranslations, translationContent } =
|
|
||||||
useTranslations(contentAttributes);
|
it('falls back to account locale when agent locale not found', () => {
|
||||||
expect(hasTranslations.value).toBe(true);
|
const translations = { en: 'Hello', zh_CN: '你好' };
|
||||||
// Should return the first translation (en: 'Hello')
|
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
|
||||||
expect(translationContent.value).toBe('Hello');
|
});
|
||||||
|
|
||||||
|
it('returns first translation when both locales are undefined', () => {
|
||||||
|
const translations = { en: 'Hello', es: 'Hola' };
|
||||||
|
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useUISettings } from './useUISettings';
|
||||||
|
import { useAccount } from './useAccount';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select translation based on locale priority.
|
||||||
|
* @param {Object} translations - Translations object with locale keys
|
||||||
|
* @param {string} agentLocale - Agent's preferred locale
|
||||||
|
* @param {string} accountLocale - Account's default locale
|
||||||
|
* @returns {string|null} Selected translation or null
|
||||||
|
*/
|
||||||
|
export function selectTranslation(translations, agentLocale, accountLocale) {
|
||||||
|
if (!translations || Object.keys(translations).length === 0) return null;
|
||||||
|
|
||||||
|
if (agentLocale && translations[agentLocale]) {
|
||||||
|
return translations[agentLocale];
|
||||||
|
}
|
||||||
|
if (accountLocale && translations[accountLocale]) {
|
||||||
|
return translations[accountLocale];
|
||||||
|
}
|
||||||
|
return translations[Object.keys(translations)[0]];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable to extract translation state/content from contentAttributes.
|
* Composable to extract translation state/content from contentAttributes.
|
||||||
@@ -6,6 +27,9 @@ import { computed } from 'vue';
|
|||||||
* @returns {Object} { hasTranslations, translationContent }
|
* @returns {Object} { hasTranslations, translationContent }
|
||||||
*/
|
*/
|
||||||
export function useTranslations(contentAttributes) {
|
export function useTranslations(contentAttributes) {
|
||||||
|
const { uiSettings } = useUISettings();
|
||||||
|
const { currentAccount } = useAccount();
|
||||||
|
|
||||||
const hasTranslations = computed(() => {
|
const hasTranslations = computed(() => {
|
||||||
if (!contentAttributes.value) return false;
|
if (!contentAttributes.value) return false;
|
||||||
const { translations = {} } = contentAttributes.value;
|
const { translations = {} } = contentAttributes.value;
|
||||||
@@ -14,8 +38,11 @@ export function useTranslations(contentAttributes) {
|
|||||||
|
|
||||||
const translationContent = computed(() => {
|
const translationContent = computed(() => {
|
||||||
if (!hasTranslations.value) return null;
|
if (!hasTranslations.value) return null;
|
||||||
const translations = contentAttributes.value.translations;
|
return selectTranslation(
|
||||||
return translations[Object.keys(translations)[0]];
|
contentAttributes.value.translations,
|
||||||
|
uiSettings.value?.locale,
|
||||||
|
currentAccount.value?.locale
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { hasTranslations, translationContent };
|
return { hasTranslations, translationContent };
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export default {
|
|||||||
...mapGetters({
|
...mapGetters({
|
||||||
getAccount: 'accounts/getAccount',
|
getAccount: 'accounts/getAccount',
|
||||||
currentAccountId: 'getCurrentAccountId',
|
currentAccountId: 'getCurrentAccountId',
|
||||||
|
getUISettings: 'getUISettings',
|
||||||
}),
|
}),
|
||||||
plainTextContent() {
|
plainTextContent() {
|
||||||
return this.getPlainText(this.messageContent);
|
return this.getPlainText(this.messageContent);
|
||||||
@@ -117,11 +118,13 @@ export default {
|
|||||||
this.$emit('close', e);
|
this.$emit('close', e);
|
||||||
},
|
},
|
||||||
handleTranslate() {
|
handleTranslate() {
|
||||||
const { locale } = this.getAccount(this.currentAccountId);
|
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
|
||||||
|
const agentLocale = this.getUISettings?.locale;
|
||||||
|
const targetLanguage = agentLocale || accountLocale || 'en';
|
||||||
this.$store.dispatch('translateMessage', {
|
this.$store.dispatch('translateMessage', {
|
||||||
conversationId: this.conversationId,
|
conversationId: this.conversationId,
|
||||||
messageId: this.messageId,
|
messageId: this.messageId,
|
||||||
targetLanguage: locale || 'en',
|
targetLanguage,
|
||||||
});
|
});
|
||||||
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
||||||
this.handleClose();
|
this.handleClose();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class Integrations::GoogleTranslate::ProcessorService
|
|||||||
|
|
||||||
response = client.translate_text(
|
response = client.translate_text(
|
||||||
contents: [content],
|
contents: [content],
|
||||||
target_language_code: target_language,
|
target_language_code: bcp47_language_code,
|
||||||
parent: "projects/#{hook.settings['project_id']}",
|
parent: "projects/#{hook.settings['project_id']}",
|
||||||
mime_type: mime_type
|
mime_type: mime_type
|
||||||
)
|
)
|
||||||
@@ -23,6 +23,10 @@ class Integrations::GoogleTranslate::ProcessorService
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def bcp47_language_code
|
||||||
|
target_language.tr('_', '-')
|
||||||
|
end
|
||||||
|
|
||||||
def email_channel?
|
def email_channel?
|
||||||
message&.inbox&.email?
|
message&.inbox&.email?
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user