mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 20:18:08 +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
|
||||
@articles = @portal.articles.published.includes(:category, :author)
|
||||
|
||||
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||
|
||||
@articles_count = @articles.count
|
||||
|
||||
search_articles
|
||||
order_by_sort_param
|
||||
limit_results
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
defineProps({
|
||||
url: {
|
||||
@@ -11,6 +12,8 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['back', 'insert']);
|
||||
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const onBack = e => {
|
||||
e.stopPropagation();
|
||||
emit('back');
|
||||
@@ -35,7 +38,7 @@ const onInsert = e => {
|
||||
</div>
|
||||
<div class="-ml-4 h-full overflow-y-auto">
|
||||
<div class="w-full h-full min-h-0">
|
||||
<IframeLoader :url="url" />
|
||||
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -112,11 +112,14 @@ export const InitializationHelpers = {
|
||||
},
|
||||
|
||||
setDirectionAttribute: () => {
|
||||
const portalElement = document.getElementById('portal');
|
||||
if (!portalElement) return;
|
||||
const htmlElement = document.querySelector('html');
|
||||
// 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;
|
||||
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
|
||||
const localeFromHtml = htmlElement.lang;
|
||||
htmlElement.dir =
|
||||
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
|
||||
},
|
||||
|
||||
initializeThemesInPortal: initializeTheme,
|
||||
|
||||
@@ -1,64 +1,56 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, useTemplateRef, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ArticleSkeletonLoader from 'shared/components/ArticleSkeletonLoader.vue';
|
||||
|
||||
export default {
|
||||
name: 'IframeLoader',
|
||||
components: {
|
||||
ArticleSkeletonLoader,
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isRtl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isRtl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: true,
|
||||
showEmptyState: !this.url,
|
||||
};
|
||||
isDirApplied: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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) {
|
||||
iframeDocument.documentElement.dir = value ? 'rtl' : 'ltr';
|
||||
}
|
||||
} catch (e) {
|
||||
// error
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleIframeLoad() {
|
||||
// Once loaded, the loading state is hidden
|
||||
this.isLoading = false;
|
||||
},
|
||||
handleIframeError() {
|
||||
// Hide the loading state and show the empty state when an error occurs
|
||||
this.isLoading = false;
|
||||
this.showEmptyState = true;
|
||||
},
|
||||
},
|
||||
const { t } = useI18n();
|
||||
|
||||
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) {
|
||||
// error
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.isRtl, applyDirection);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
isLoading.value = false;
|
||||
applyDirection();
|
||||
};
|
||||
|
||||
const handleIframeError = () => {
|
||||
isLoading.value = false;
|
||||
showEmptyState.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -66,6 +58,7 @@ export default {
|
||||
<div class="relative overflow-hidden pb-1/2 h-full">
|
||||
<iframe
|
||||
v-if="url"
|
||||
ref="iframe"
|
||||
:src="url"
|
||||
class="absolute w-full h-full top-0 left-0"
|
||||
@load="handleIframeLoad"
|
||||
@@ -79,7 +72,7 @@ export default {
|
||||
v-if="showEmptyState"
|
||||
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>
|
||||
</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 { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
|
||||
|
||||
const locale = computed(() => {
|
||||
const { locale: selectedLocale } = i18n;
|
||||
const {
|
||||
allowed_locales: 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 { allowed_locales: allowedLocales } = portal.value.config;
|
||||
return getMatchingLocale(selectedLocale.value, allowedLocales);
|
||||
});
|
||||
|
||||
const fetchArticles = () => {
|
||||
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
|
||||
const params = new URLSearchParams({
|
||||
show_plain_layout: 'true',
|
||||
theme: prefersDarkMode.value ? 'dark' : 'light',
|
||||
...(locale.value && { locale: locale.value }),
|
||||
});
|
||||
|
||||
// Combine link with query parameters
|
||||
@@ -64,7 +57,8 @@ const hasArticles = computed(
|
||||
() =>
|
||||
!articleUiFlags.value.isFetching &&
|
||||
!articleUiFlags.value.isError &&
|
||||
!!popularArticles.value.length
|
||||
!!popularArticles.value.length &&
|
||||
!!locale.value
|
||||
);
|
||||
onMounted(() => fetchArticles());
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,7 @@ export const actions = {
|
||||
commit('setError', false);
|
||||
|
||||
try {
|
||||
if (!locale) return;
|
||||
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
|
||||
if (cachedData) {
|
||||
commit('setArticles', cachedData);
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
<script>
|
||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
|
||||
export default {
|
||||
name: 'ArticleViewer',
|
||||
components: {
|
||||
IframeLoader,
|
||||
},
|
||||
computed: {
|
||||
isRTL() {
|
||||
return this.$root.$i18n.locale
|
||||
? getLanguageDirection(this.$root.$i18n.locale)
|
||||
: false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white h-full">
|
||||
<IframeLoader :url="$route.query.link" :is-rtl="isRTL" />
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -65,6 +65,14 @@ RSpec.describe 'Public Articles API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
|
||||
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)
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user