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