fix: slim help center search results (#13761)

Fixes help center public article search so query responses stay compact
and locale-scoped. Whitespace-only queries are now treated as empty in
both the portal UI and the server-side search path, and search
suggestions stay aligned with the trimmed query.

Fixes: https://github.com/chatwoot/chatwoot/issues/10402
Closes: https://github.com/chatwoot/chatwoot/issues/10402

## Why

The public help center search endpoint reused the full article
serializer for query responses, which returned much more data than the
search suggestions UI needed. That made responses heavier than necessary
and also surfaced nested portal and category data that made the results
look cross-locale.

Whitespace-only searches could also still reach the backend search path,
and in Enterprise that meant embedding search could be invoked for a
blank query.

## What changed

- return a compact search-specific payload for article query responses
- keep the existing full article serializer for normal article listing
responses
- preserve current-locale search behavior for the portal search flow
- trim whitespace-only search terms on the client so they do not open
suggestions or trigger a request
- reuse the normalized query on the backend so whitespace-only requests
are treated as empty searches in both OSS and Enterprise paths
- pass the trimmed search term into suggestions so highlighting matches
the actual query being sent
- add request and frontend regression coverage for compact payloads,
locale scoping, and whitespace-only search behavior

## Validation

1. Open `/hc/:portal/:locale` in the public help center.
2. Enter only spaces in the search box and confirm suggestions do not
open.
3. Search for a real term and confirm suggestions appear.
4. Verify the results are limited to the active locale.
5. Click a suggestion and confirm it opens the correct article page.
6. Inspect the query response and confirm it returns the compact search
payload instead of the full article serializer.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sojan Jose
2026-03-17 00:46:23 -07:00
committed by GitHub
parent ad1539c6cf
commit 270f3c6a80
9 changed files with 224 additions and 21 deletions

View File

@@ -6,6 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal'
def index
@search_query = list_params[:query]
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@@ -73,7 +74,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
params.permit(:query, :locale, :sort, :status, :page, :per_page)
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
permitted[:query] = permitted[:query].to_s.strip.presence
end
end
def permitted_params

View File

@@ -26,8 +26,11 @@ export default {
localeCode() {
return window.portalConfig.localeCode;
},
normalizedSearchTerm() {
return this.searchTerm.trim();
},
shouldShowSearchBox() {
return this.searchTerm !== '' && this.showSearchBox;
return this.normalizedSearchTerm !== '' && this.showSearchBox;
},
searchTranslations() {
const { searchTranslations = {} } = window.portalConfig;
@@ -52,6 +55,13 @@ export default {
clearTimeout(this.typingTimer);
}
if (this.normalizedSearchTerm === '') {
this.searchResults = [];
this.isLoading = false;
this.closeSearch();
return;
}
this.openSearch();
this.isLoading = true;
this.typingTimer = setTimeout(() => {
@@ -74,16 +84,21 @@ export default {
this.searchTerm = '';
},
async fetchArticlesByQuery() {
const query = this.normalizedSearchTerm;
if (!query) {
this.isLoading = false;
return;
}
try {
this.isLoading = true;
this.searchResults = [];
const { data } = await ArticlesAPI.searchArticles(
this.portalSlug,
this.localeCode,
this.searchTerm
query
);
this.searchResults = data.payload;
this.isLoading = true;
} catch (error) {
// Show something wrong message
} finally {
@@ -110,7 +125,7 @@ export default {
<SearchSuggestions
:items="searchResults"
:is-loading="isLoading"
:search-term="searchTerm"
:search-term="normalizedSearchTerm"
:empty-placeholder="searchTranslations.emptyPlaceholder"
:results-title="searchTranslations.resultsTitle"
:loading-placeholder="searchTranslations.loadingPlaceholder"

View File

@@ -66,9 +66,6 @@ export default {
},
methods: {
generateArticleUrl(article) {
return `/hc/${article.portal.slug}/articles/${article.slug}`;
},
prepareContent(content) {
return this.highlightContent(
content,
@@ -107,10 +104,7 @@ export default {
@mouse-enter="onHover(index)"
@mouse-leave="onHover(-1)"
>
<a
class="flex flex-col gap-1 overflow-y-hidden"
:href="generateArticleUrl(article)"
>
<a class="flex flex-col gap-1 overflow-y-hidden" :href="article.link">
<span
v-dompurify-html="prepareContent(article.title)"
class="flex-auto w-full overflow-hidden text-base font-semibold leading-6 truncate text-ellipsis whitespace-nowrap"

View File

@@ -0,0 +1,90 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PublicArticleSearch from '../components/PublicArticleSearch.vue';
import ArticlesAPI from '../api/article';
vi.mock('../api/article', () => ({
default: {
searchArticles: vi.fn(),
},
}));
describe('PublicArticleSearch', () => {
let originalPortalConfig;
const SearchSuggestionsStub = {
name: 'SearchSuggestions',
template: '<div />',
props: ['searchTerm'],
};
beforeEach(() => {
vi.useFakeTimers();
originalPortalConfig = window.portalConfig;
window.portalConfig = {
portalSlug: 'test-portal',
localeCode: 'en',
searchTranslations: {},
};
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
window.portalConfig = originalPortalConfig;
});
const buildWrapper = () =>
shallowMount(PublicArticleSearch, {
global: {
directives: {
onClickaway: () => {},
},
stubs: {
SearchSuggestions: SearchSuggestionsStub,
PublicSearchInput: true,
},
},
});
it('does not fetch or show suggestions for whitespace-only search terms', async () => {
const wrapper = buildWrapper();
wrapper.vm.searchResults = [{ id: 1 }];
wrapper.vm.showSearchBox = true;
wrapper.vm.onUpdateSearchTerm(' ');
await wrapper.vm.$nextTick();
vi.runAllTimers();
await flushPromises();
expect(ArticlesAPI.searchArticles).not.toHaveBeenCalled();
expect(wrapper.vm.searchResults).toEqual([]);
expect(wrapper.vm.shouldShowSearchBox).toBe(false);
expect(wrapper.vm.isLoading).toBe(false);
});
it('trims the search term before requesting articles', async () => {
ArticlesAPI.searchArticles.mockResolvedValue({ data: { payload: [] } });
const wrapper = buildWrapper();
wrapper.vm.onUpdateSearchTerm(' chatwoot ');
vi.runAllTimers();
await flushPromises();
expect(ArticlesAPI.searchArticles).toHaveBeenCalledWith(
'test-portal',
'en',
'chatwoot'
);
});
it('passes the trimmed search term to suggestions for highlighting', async () => {
const wrapper = buildWrapper();
wrapper.vm.onUpdateSearchTerm(' chatwoot ');
await wrapper.vm.$nextTick();
expect(
wrapper.findComponent(SearchSuggestionsStub).props('searchTerm')
).toBe('chatwoot');
});
});

View File

@@ -0,0 +1,43 @@
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import SearchSuggestions from '../components/SearchSuggestions.vue';
vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
useKeyboardNavigableList: vi.fn(() => ({})),
}));
vi.mock('shared/composables/useMessageFormatter', () => ({
useMessageFormatter: () => ({
highlightContent: content => content,
}),
}));
describe('SearchSuggestions', () => {
it('renders suggestion links from the backend-provided link field', () => {
const wrapper = mount(SearchSuggestions, {
props: {
items: [
{
id: 1,
title: 'Chatwoot Glossary',
content: 'Access Token',
link: '/hc/user-guide/articles/1677141565-chatwoot-glossary',
},
],
isLoading: false,
searchTerm: 'chatwoot',
},
global: {
directives: {
dompurifyHtml: (element, binding) => {
element.innerHTML = binding.value;
},
},
},
});
expect(wrapper.find('a').attributes('href')).toBe(
'/hc/user-guide/articles/1677141565-chatwoot-glossary'
);
});
});

View File

@@ -0,0 +1,7 @@
content = article.content.to_s.squish
snippet = excerpt(content, search_query, radius: 110, omission: '...') ||
truncate(content, length: 220, separator: ' ')
json.extract! article, :id, :category_id, :title
json.content snippet
json.link generate_article_link(portal_slug, article.slug, nil, false)

View File

@@ -1,6 +1,14 @@
json.payload do
json.array! @articles.includes([:category, :associated_articles, { author: { avatar_attachment: [:blob] } }]),
partial: 'public/api/v1/models/article', formats: [:json], as: :article
if @search_query.present?
json.array! @articles,
partial: 'public/api/v1/models/search_article',
formats: [:json],
as: :article,
locals: { portal_slug: @portal.slug, search_query: @search_query }
else
json.array! @articles.includes([:category, :associated_articles, { author: { avatar_attachment: [:blob] } }]),
partial: 'public/api/v1/models/article', formats: [:json], as: :article
end
end
json.meta do

View File

@@ -34,18 +34,53 @@ RSpec.describe 'Public Articles API', type: :request do
end
it 'get all articles with searched text query' do
article2 = create(:article,
account_id: account.id,
portal: portal,
category: category,
author_id: agent.id,
content: 'this is some test and funny content')
expect(article2.id).not_to be_nil
long_content = ([('intro ' * 30).strip, 'funny', ('tail ' * 30).strip].join(' ')).strip
create(:article,
account_id: account.id,
portal: portal,
category: category,
author_id: agent.id,
content: long_content)
get "/hc/#{portal.slug}/#{category.locale}/categories/#{category.slug}/articles.json", params: { query: 'funny' }
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.length).to eq(1)
expect(response_data[0].keys).to match_array(%i[id category_id title content link])
expect(response_data[0][:content]).to include('funny')
expect(response_data[0][:content].length).to be < long_content.length
end
it 'limits search results to the current locale' do
create(:article,
account_id: account.id,
portal: portal,
category: category,
author_id: agent.id,
title: 'English locale result',
content: 'shared-search-term in english')
create(:article,
account_id: account.id,
portal: portal,
category: category_2,
author_id: agent.id,
title: 'Spanish locale result',
content: 'shared-search-term in spanish')
get "/hc/#{portal.slug}/#{category.locale}/articles.json", params: { query: 'shared-search-term' }
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.pluck(:title)).to eq(['English locale result'])
end
it 'treats whitespace-only queries as empty searches' do
get "/hc/#{portal.slug}/#{category.locale}/articles.json", params: { query: ' ' }
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.length).to eq(3)
expect(response_data.first).to include(:description, :slug, :portal)
end
it 'get all popular articles if sort params is passed' do

View File

@@ -14,6 +14,14 @@ RSpec.describe 'Public Articles API', type: :request do
get "/hc/#{portal.slug}/en/articles.json", params: { query: 'funny' }
expect(Article).to have_received(:vector_search)
end
it 'does not use vector search for whitespace-only queries' do
allow(Article).to receive(:vector_search)
get "/hc/#{portal.slug}/en/articles.json", params: { query: ' ' }
expect(Article).not_to have_received(:vector_search)
end
end
end
end