Files
leadchat/app/javascript/portal/components/SearchSuggestions.vue
Sojan Jose 270f3c6a80 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>
2026-03-17 00:46:23 -07:00

128 lines
3.5 KiB
Vue

<script>
import { ref, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
export default {
props: {
items: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
emptyPlaceholder: {
type: String,
default: '',
},
loadingPlaceholder: {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
},
setup(props) {
const selectedIndex = ref(-1);
const portalSearchSuggestionsRef = ref(null);
const { highlightContent } = useMessageFormatter();
const adjustScroll = () => {
nextTick(() => {
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
});
};
const isSearchItemActive = index => {
return index === selectedIndex.value
? 'bg-slate-25 dark:bg-slate-800'
: 'bg-white dark:bg-slate-900';
};
useKeyboardNavigableList({
items: computed(() => props.items),
adjustScroll,
selectedIndex,
});
return {
selectedIndex,
portalSearchSuggestionsRef,
isSearchItemActive,
highlightContent,
};
},
computed: {
showEmptyResults() {
return !this.items.length && !this.isLoading;
},
shouldShowResults() {
return this.items.length && !this.isLoading;
},
},
methods: {
prepareContent(content) {
return this.highlightContent(
content,
this.searchTerm,
'bg-slate-100 dark:bg-slate-700 font-semibold text-slate-600 dark:text-slate-200'
);
},
},
};
</script>
<template>
<div
ref="portalSearchSuggestionsRef"
class="p-5 mt-2 overflow-y-auto text-sm bg-white border border-solid rounded-lg shadow-xl hover:shadow-lg dark:bg-slate-900 max-h-96 scroll-py-2 text-slate-700 dark:text-slate-100 border-slate-50 dark:border-slate-800"
>
<div
v-if="isLoading"
class="text-sm font-medium text-slate-400 dark:text-slate-700"
>
{{ loadingPlaceholder }}
</div>
<ul
v-if="shouldShowResults"
class="flex flex-col gap-4 text-sm bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100"
role="listbox"
>
<li
v-for="(article, index) in items"
:id="article.id"
:key="article.id"
class="flex items-center p-4 border border-solid rounded-lg cursor-pointer select-none group hover:bg-slate-25 dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800"
:class="isSearchItemActive(index)"
role="option"
tabindex="-1"
@mouse-enter="onHover(index)"
@mouse-leave="onHover(-1)"
>
<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"
/>
<div
v-dompurify-html="prepareContent(article.content)"
class="overflow-hidden text-sm line-clamp-2 text-ellipsis whitespace-nowrap text-slate-600 dark:text-slate-300"
/>
</a>
</li>
</ul>
<div
v-if="showEmptyResults"
class="text-sm font-medium text-slate-400 dark:text-slate-700"
>
{{ emptyPlaceholder }}
</div>
</div>
</template>