From 270f3c6a80f361e147ab53ac8063eac9a91a9ad0 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 17 Mar 2026 00:46:23 -0700 Subject: [PATCH] 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 --- .../api/v1/portals/articles_controller.rb | 5 +- .../portal/components/PublicArticleSearch.vue | 23 ++++- .../portal/components/SearchSuggestions.vue | 8 +- .../portal/specs/PublicArticleSearch.spec.js | 90 +++++++++++++++++++ .../portal/specs/SearchSuggestions.spec.js | 43 +++++++++ .../v1/models/_search_article.json.jbuilder | 7 ++ .../v1/portals/articles/index.json.jbuilder | 12 ++- .../v1/portals/articles_controller_spec.rb | 49 ++++++++-- .../v1/portals/articles_controller_spec.rb | 8 ++ 9 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 app/javascript/portal/specs/PublicArticleSearch.spec.js create mode 100644 app/javascript/portal/specs/SearchSuggestions.spec.js create mode 100644 app/views/public/api/v1/models/_search_article.json.jbuilder diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index e6cc2aa69..664a1964f 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -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 diff --git a/app/javascript/portal/components/PublicArticleSearch.vue b/app/javascript/portal/components/PublicArticleSearch.vue index 809755e46..e27f56d43 100644 --- a/app/javascript/portal/components/PublicArticleSearch.vue +++ b/app/javascript/portal/components/PublicArticleSearch.vue @@ -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 { - + ({ + default: { + searchArticles: vi.fn(), + }, +})); + +describe('PublicArticleSearch', () => { + let originalPortalConfig; + const SearchSuggestionsStub = { + name: 'SearchSuggestions', + template: '
', + 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'); + }); +}); diff --git a/app/javascript/portal/specs/SearchSuggestions.spec.js b/app/javascript/portal/specs/SearchSuggestions.spec.js new file mode 100644 index 000000000..6352c1725 --- /dev/null +++ b/app/javascript/portal/specs/SearchSuggestions.spec.js @@ -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' + ); + }); +}); diff --git a/app/views/public/api/v1/models/_search_article.json.jbuilder b/app/views/public/api/v1/models/_search_article.json.jbuilder new file mode 100644 index 000000000..28fc9559e --- /dev/null +++ b/app/views/public/api/v1/models/_search_article.json.jbuilder @@ -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) diff --git a/app/views/public/api/v1/portals/articles/index.json.jbuilder b/app/views/public/api/v1/portals/articles/index.json.jbuilder index 43a9a173a..428064972 100644 --- a/app/views/public/api/v1/portals/articles/index.json.jbuilder +++ b/app/views/public/api/v1/portals/articles/index.json.jbuilder @@ -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 diff --git a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb index 2d8c5fcd5..515013f37 100644 --- a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb +++ b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb @@ -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 diff --git a/spec/enterprise/controllers/enterprise/public/api/v1/portals/articles_controller_spec.rb b/spec/enterprise/controllers/enterprise/public/api/v1/portals/articles_controller_spec.rb index 6fa94c55d..72faf4300 100644 --- a/spec/enterprise/controllers/enterprise/public/api/v1/portals/articles_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/public/api/v1/portals/articles_controller_spec.rb @@ -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