mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 12:37:56 +00:00
feat: Sync Popular Articles locale with widget locale (#11754)
# Pull Request Template
## Description
This PR includes the following improvements:
* **Popular Articles Locale Selection based on Widget Locale**
* Implements priority-based locale matching:
* Exact locale match (e.g., "fr" === "fr")
* Base language match (e.g., "fr" when selected is "fr_CA")
* Variant match (e.g., "fr_BE" when selected is "fr")
* Removes default locale fallback - if no locale match is found, popular
articles section is hidden.
* Fixed **API** filter issue where the locale parameter was previously
ignored
* Hides Popular Articles section completely when no locale match is
found and Only shows relevant articles in the user's language
* **RTL Direction Handling Improvements**
* Now directly reads the `lang` attribute from HTML element `<html
lang="en">` instead of relying on `.locale-switcher` and sets direction
attribute based on language.
* Adds `data-dir-applied` attribute to prevent overlapping direction
settings between global helpers and components (eg case: Insert article
in editor dashboard)
* Update `IframeLoader.vue` to Composition API and improve the **dir**
logic
Fixes
1.
[CW-4505](https://linear.app/chatwoot/issue/CW-4505/popular-articles-not-displayed-based-on-user-locale-in-live-chat),
https://github.com/chatwoot/chatwoot/issues/11745
2. RTL direction is not working in widget article view after merging
this PR https://github.com/chatwoot/chatwoot/pull/11692
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Loom video
**Popular Articles**
https://www.loom.com/share/7cecbaaa77eb48e19263398b6ba8ddef?sid=a2452b8e-7d7e-46a3-b5c8-aed5ab5bc801
**RTL improvements**
https://www.loom.com/share/3ccad77174a0412097e802641df5f3e0?sid=e10ac57f-5c49-4084-84d3-5ad58aee54fa
## Checklist:
- [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
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
@articles = @portal.articles.published.includes(:category, :author)
|
@articles = @portal.articles.published.includes(:category, :author)
|
||||||
|
|
||||||
|
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||||
|
|
||||||
@articles_count = @articles.count
|
@articles_count = @articles.count
|
||||||
|
|
||||||
search_articles
|
search_articles
|
||||||
order_by_sort_param
|
order_by_sort_param
|
||||||
limit_results
|
limit_results
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
url: {
|
url: {
|
||||||
@@ -11,6 +12,8 @@ defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['back', 'insert']);
|
const emit = defineEmits(['back', 'insert']);
|
||||||
|
|
||||||
|
const isRTL = useMapGetter('accounts/isRTL');
|
||||||
|
|
||||||
const onBack = e => {
|
const onBack = e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
emit('back');
|
emit('back');
|
||||||
@@ -35,7 +38,7 @@ const onInsert = e => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="-ml-4 h-full overflow-y-auto">
|
<div class="-ml-4 h-full overflow-y-auto">
|
||||||
<div class="w-full h-full min-h-0">
|
<div class="w-full h-full min-h-0">
|
||||||
<IframeLoader :url="url" />
|
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -112,11 +112,14 @@ export const InitializationHelpers = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
setDirectionAttribute: () => {
|
setDirectionAttribute: () => {
|
||||||
const portalElement = document.getElementById('portal');
|
const htmlElement = document.querySelector('html');
|
||||||
if (!portalElement) return;
|
// If direction is already applied through props, do not apply again (iframe case)
|
||||||
|
const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
|
||||||
|
if (!htmlElement || hasDirApplied) return;
|
||||||
|
|
||||||
const locale = document.querySelector('.locale-switcher')?.value;
|
const localeFromHtml = htmlElement.lang;
|
||||||
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
|
htmlElement.dir =
|
||||||
|
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
|
||||||
},
|
},
|
||||||
|
|
||||||
initializeThemesInPortal: initializeTheme,
|
initializeThemesInPortal: initializeTheme,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, useTemplateRef, computed, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
|
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
name: 'IframeLoader',
|
|
||||||
components: {
|
|
||||||
ArticleSkeletonLoader,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
url: {
|
url: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
@@ -15,50 +12,45 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
isDirApplied: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
},
|
},
|
||||||
data() {
|
});
|
||||||
return {
|
|
||||||
isLoading: true,
|
|
||||||
showEmptyState: !this.url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
isRtl: {
|
|
||||||
immediate: true,
|
|
||||||
handler(value) {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const iframeElement = this.$el.querySelector('iframe');
|
|
||||||
if (iframeElement) {
|
|
||||||
iframeElement.onload = () => {
|
|
||||||
try {
|
|
||||||
const iframeDocument =
|
|
||||||
iframeElement.contentDocument ||
|
|
||||||
(iframeElement.contentWindow &&
|
|
||||||
iframeElement.contentWindow.document);
|
|
||||||
|
|
||||||
if (iframeDocument) {
|
const { t } = useI18n();
|
||||||
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
|
|
||||||
|
const iframe = useTemplateRef('iframe');
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const showEmptyState = ref(!props.url);
|
||||||
|
|
||||||
|
const direction = computed(() => (props.isRtl ? 'rtl' : 'ltr'));
|
||||||
|
|
||||||
|
const applyDirection = () => {
|
||||||
|
if (!iframe.value) return;
|
||||||
|
if (!props.isDirApplied) return; // If direction is already applied through props, do not apply again (iframe case)
|
||||||
|
try {
|
||||||
|
const doc =
|
||||||
|
iframe.value.contentDocument || iframe.value.contentWindow?.document;
|
||||||
|
if (doc?.documentElement) {
|
||||||
|
doc.documentElement.dir = direction.value;
|
||||||
|
doc.documentElement.setAttribute('data-dir-applied', 'true');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// error
|
// error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
|
||||||
});
|
watch(() => props.isRtl, applyDirection);
|
||||||
},
|
|
||||||
},
|
const handleIframeLoad = () => {
|
||||||
},
|
isLoading.value = false;
|
||||||
methods: {
|
applyDirection();
|
||||||
handleIframeLoad() {
|
};
|
||||||
// Once loaded, the loading state is hidden
|
|
||||||
this.isLoading = false;
|
const handleIframeError = () => {
|
||||||
},
|
isLoading.value = false;
|
||||||
handleIframeError() {
|
showEmptyState.value = true;
|
||||||
// Hide the loading state and show the empty state when an error occurs
|
|
||||||
this.isLoading = false;
|
|
||||||
this.showEmptyState = true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -66,6 +58,7 @@ export default {
|
|||||||
<div class="relative overflow-hidden pb-1/2 h-full">
|
<div class="relative overflow-hidden pb-1/2 h-full">
|
||||||
<iframe
|
<iframe
|
||||||
v-if="url"
|
v-if="url"
|
||||||
|
ref="iframe"
|
||||||
:src="url"
|
:src="url"
|
||||||
class="absolute w-full h-full top-0 left-0"
|
class="absolute w-full h-full top-0 left-0"
|
||||||
@load="handleIframeLoad"
|
@load="handleIframeLoad"
|
||||||
@@ -79,7 +72,7 @@ export default {
|
|||||||
v-if="showEmptyState"
|
v-if="showEmptyState"
|
||||||
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
|
class="absolute w-full h-full top-0 left-0 flex justify-center items-center"
|
||||||
>
|
>
|
||||||
<p>{{ $t('PORTAL.IFRAME_ERROR') }}</p>
|
<p>{{ t('PORTAL.IFRAME_ERROR') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
40
app/javascript/shared/helpers/portalHelper.js
Normal file
40
app/javascript/shared/helpers/portalHelper.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Determine the best-matching locale from the list of locales allowed by the portal.
|
||||||
|
*
|
||||||
|
* The matching happens in the following order:
|
||||||
|
* 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
|
||||||
|
* (e.g., `fr` ➜ `fr`).
|
||||||
|
* 2. Base language match – the base part of a compound locale (before the underscore)
|
||||||
|
* matches (e.g., `fr_CA` ➜ `fr`).
|
||||||
|
* 3. Variant match – when the base language is selected but a regional variant exists
|
||||||
|
* in the portal list (e.g., `fr` ➜ `fr_BE`).
|
||||||
|
*
|
||||||
|
* If none of these rules find a match, the function returns `null`,
|
||||||
|
* Don't show popular articles if locale doesn't match with allowed locales
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
|
||||||
|
* @param {string[]} allowedLocales Array of locales enabled for the portal.
|
||||||
|
* @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
|
||||||
|
*/
|
||||||
|
export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
|
||||||
|
// Ensure inputs are valid
|
||||||
|
if (
|
||||||
|
!selectedLocale ||
|
||||||
|
!Array.isArray(allowedLocales) ||
|
||||||
|
!allowedLocales.length
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lang] = selectedLocale.split('_');
|
||||||
|
|
||||||
|
const priorityMatches = [
|
||||||
|
selectedLocale, // exact match
|
||||||
|
lang, // base language match
|
||||||
|
allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
|
||||||
|
];
|
||||||
|
|
||||||
|
// Return the first match that exists in the allowed list, or null
|
||||||
|
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
|
||||||
|
};
|
||||||
28
app/javascript/shared/helpers/specs/portalHelper.spec.js
Normal file
28
app/javascript/shared/helpers/specs/portalHelper.spec.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||||
|
|
||||||
|
describe('portalHelper - getMatchingLocale', () => {
|
||||||
|
it('returns exact match when present', () => {
|
||||||
|
const result = getMatchingLocale('fr', ['en', 'fr']);
|
||||||
|
expect(result).toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns base language match when exact variant not present', () => {
|
||||||
|
const result = getMatchingLocale('fr_CA', ['en', 'fr']);
|
||||||
|
expect(result).toBe('fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns variant match when base language not present', () => {
|
||||||
|
const result = getMatchingLocale('fr', ['en', 'fr_BE']);
|
||||||
|
expect(result).toBe('fr_BE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no match found', () => {
|
||||||
|
const result = getMatchingLocale('de', ['en', 'fr']);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid inputs', () => {
|
||||||
|
expect(getMatchingLocale('', [])).toBeNull();
|
||||||
|
expect(getMatchingLocale(null, null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||||
|
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
|
|||||||
|
|
||||||
const locale = computed(() => {
|
const locale = computed(() => {
|
||||||
const { locale: selectedLocale } = i18n;
|
const { locale: selectedLocale } = i18n;
|
||||||
const {
|
const { allowed_locales: allowedLocales } = portal.value.config;
|
||||||
allowed_locales: allowedLocales,
|
return getMatchingLocale(selectedLocale.value, allowedLocales);
|
||||||
default_locale: defaultLocale = 'en',
|
|
||||||
} = portal.value.config;
|
|
||||||
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
|
|
||||||
// If the exact match of a locale is available in the list of portal locales, return it
|
|
||||||
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
|
|
||||||
if (allowedLocales.includes(selectedLocale)) {
|
|
||||||
return locale;
|
|
||||||
}
|
|
||||||
return defaultLocale;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchArticles = () => {
|
const fetchArticles = () => {
|
||||||
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
show_plain_layout: 'true',
|
show_plain_layout: 'true',
|
||||||
theme: prefersDarkMode.value ? 'dark' : 'light',
|
theme: prefersDarkMode.value ? 'dark' : 'light',
|
||||||
|
...(locale.value && { locale: locale.value }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Combine link with query parameters
|
// Combine link with query parameters
|
||||||
@@ -64,7 +57,8 @@ const hasArticles = computed(
|
|||||||
() =>
|
() =>
|
||||||
!articleUiFlags.value.isFetching &&
|
!articleUiFlags.value.isFetching &&
|
||||||
!articleUiFlags.value.isError &&
|
!articleUiFlags.value.isError &&
|
||||||
!!popularArticles.value.length
|
!!popularArticles.value.length &&
|
||||||
|
!!locale.value
|
||||||
);
|
);
|
||||||
onMounted(() => fetchArticles());
|
onMounted(() => fetchArticles());
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const actions = {
|
|||||||
commit('setError', false);
|
commit('setError', false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!locale) return;
|
||||||
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
|
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
|
||||||
if (cachedData) {
|
if (cachedData) {
|
||||||
commit('setArticles', cachedData);
|
commit('setArticles', cachedData);
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
<script>
|
<script>
|
||||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ArticleViewer',
|
name: 'ArticleViewer',
|
||||||
components: {
|
components: {
|
||||||
IframeLoader,
|
IframeLoader,
|
||||||
},
|
},
|
||||||
computed: {
|
|
||||||
isRTL() {
|
|
||||||
return this.$root.$i18n.locale
|
|
||||||
? getLanguageDirection(this.$root.$i18n.locale)
|
|
||||||
: false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-white h-full">
|
<div class="bg-white h-full">
|
||||||
<IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
|
<IframeLoader :url="$route.query.link" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ RSpec.describe 'Public Articles API', type: :request do
|
|||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
|
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
|
||||||
expect(response_data.length).to eq(2)
|
expect(response_data.length).to eq(2)
|
||||||
|
# Only count articles in the current locale (category.locale is 'en')
|
||||||
|
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(3)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns articles count from all locales when locale parameter is not present' do
|
||||||
|
get "/hc/#{portal.slug}/articles.json"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
|
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user