# Pull Request Template ## Description This PR fixes a few issues in Global Search and removes a non-functional control. * Fixes an issue in Firefox where characters could be dropped while typing in the search input. The search now uses the latest input value directly, preventing searches from running one character behind. * Removes a stale query sync in `SearchHeader` that could overwrite recently typed characters during the debounce window, causing the input to appear out of sync. * Fixes advanced search filters being removed from the URL on page reload. The search page now waits for account data to load before parsing URL parameters, ensuring agent, inbox, and date range filters are preserved. * Removes the non-functional "Sort by relevance" button from the search tabs bar, as it was disabled and had no effect. Fixes https://github.com/chatwoot/chatwoot/issues/14684 [CW-7305](https://linear.app/chatwoot/issue/CW-7305/global-search-drops-characters-while-typing-in-firefox-query-truncated) [CW-7370](https://linear.app/chatwoot/issue/CW-7370/remove-non-functional-relevance-placeholder-button-from-global-search) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screencast **Before** https://github.com/user-attachments/assets/48d72a4e-20f4-4f24-91f4-2c9a9c065eba **After** https://github.com/user-attachments/assets/0e41a803-b4fd-410d-a739-549f72d418d6 ## 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 - [ ] 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
532 lines
16 KiB
Vue
532 lines
16 KiB
Vue
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
|
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
|
import { useRouter, useRoute } from 'vue-router';
|
|
import { useTrack } from 'dashboard/composables';
|
|
import { useAccount } from 'dashboard/composables/useAccount';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
|
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
|
|
import {
|
|
ROLES,
|
|
CONVERSATION_PERMISSIONS,
|
|
CONTACT_PERMISSIONS,
|
|
PORTAL_PERMISSIONS,
|
|
} from 'dashboard/constants/permissions.js';
|
|
import { usePolicy } from 'dashboard/composables/usePolicy';
|
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
|
|
|
import Policy from 'dashboard/components/policy.vue';
|
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
|
import SearchHeader from './SearchHeader.vue';
|
|
import SearchTabs from './SearchTabs.vue';
|
|
import SearchResultConversationsList from './SearchResultConversationsList.vue';
|
|
import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
|
import SearchResultContactsList from './SearchResultContactsList.vue';
|
|
import SearchResultArticlesList from './SearchResultArticlesList.vue';
|
|
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
const store = useStore();
|
|
const { currentAccount } = useAccount();
|
|
const { t } = useI18n();
|
|
|
|
const PER_PAGE = 15; // Results per page
|
|
const selectedTab = ref(route.params.tab || 'all');
|
|
const query = ref(route.query.q || '');
|
|
const pages = ref({
|
|
contacts: 1,
|
|
conversations: 1,
|
|
messages: 1,
|
|
articles: 1,
|
|
});
|
|
|
|
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
|
|
const conversationRecords = useMapGetter(
|
|
'conversationSearch/getConversationRecords'
|
|
);
|
|
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
|
|
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
|
|
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
|
|
|
|
const addTypeToRecords = (records, type) =>
|
|
records.value.map(item => ({ ...useCamelCase(item, { deep: true }), type }));
|
|
|
|
const mappedContacts = computed(() =>
|
|
addTypeToRecords(contactRecords, 'contact')
|
|
);
|
|
const mappedConversations = computed(() =>
|
|
addTypeToRecords(conversationRecords, 'conversation')
|
|
);
|
|
const mappedMessages = computed(() =>
|
|
addTypeToRecords(messageRecords, 'message')
|
|
);
|
|
const mappedArticles = computed(() =>
|
|
addTypeToRecords(articleRecords, 'article')
|
|
);
|
|
|
|
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
|
|
|
|
const searchResultSectionClass = computed(() => ({
|
|
'mt-4': isSelectedTabAll.value,
|
|
'mt-0.5': !isSelectedTabAll.value,
|
|
}));
|
|
|
|
const sliceRecordsIfAllTab = items =>
|
|
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
|
|
|
|
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
|
|
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
|
|
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
|
|
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
|
|
|
|
const filterByTab = tab =>
|
|
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
|
|
|
|
const filterContacts = filterByTab('contacts');
|
|
const filterConversations = filterByTab('conversations');
|
|
const filterMessages = filterByTab('messages');
|
|
const filterArticles = filterByTab('articles');
|
|
|
|
const { shouldShow, isFeatureFlagEnabled } = usePolicy();
|
|
|
|
const TABS_CONFIG = {
|
|
all: {
|
|
permissions: [
|
|
CONTACT_PERMISSIONS,
|
|
...ROLES,
|
|
...CONVERSATION_PERMISSIONS,
|
|
PORTAL_PERMISSIONS,
|
|
],
|
|
count: () => null, // No count for all tab
|
|
},
|
|
contacts: {
|
|
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
|
count: () => mappedContacts.value.length,
|
|
},
|
|
conversations: {
|
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
|
count: () => mappedConversations.value.length,
|
|
},
|
|
messages: {
|
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
|
count: () => mappedMessages.value.length,
|
|
},
|
|
articles: {
|
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
|
count: () => mappedArticles.value.length,
|
|
},
|
|
};
|
|
|
|
const tabs = computed(() => {
|
|
return Object.entries(TABS_CONFIG)
|
|
.map(([key, config]) => ({
|
|
key,
|
|
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
|
|
count: config.count(),
|
|
showBadge: key !== 'all',
|
|
permissions: config.permissions,
|
|
featureFlag: config.featureFlag,
|
|
}))
|
|
.filter(config => {
|
|
// why the double check, glad you asked.
|
|
// Some features are marked as premium features, that means
|
|
// the feature will be visible, but a Paywall will be shown instead
|
|
// this works for pages and routes, but fails for UI elements like search here
|
|
// so we explicitly check if the feature is enabled
|
|
return (
|
|
shouldShow(config.featureFlag, config.permissions, null) &&
|
|
isFeatureFlagEnabled(config.featureFlag)
|
|
);
|
|
});
|
|
});
|
|
|
|
const totalSearchResultsCount = computed(() => {
|
|
const permissionCounts = [
|
|
{
|
|
permissions: [...ROLES, CONTACT_PERMISSIONS],
|
|
count: () => contacts.value.length,
|
|
},
|
|
{
|
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
|
count: () => conversations.value.length + messages.value.length,
|
|
},
|
|
{
|
|
permissions: [...ROLES, PORTAL_PERMISSIONS],
|
|
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
|
count: () => articles.value.length,
|
|
},
|
|
];
|
|
|
|
return permissionCounts
|
|
.filter(config => {
|
|
// why the double check, glad you asked.
|
|
// Some features are marked as premium features, that means
|
|
// the feature will be visible, but a Paywall will be shown instead
|
|
// this works for pages and routes, but fails for UI elements like search here
|
|
// so we explicitly check if the feature is enabled
|
|
return (
|
|
shouldShow(config.featureFlag, config.permissions, null) &&
|
|
isFeatureFlagEnabled(config.featureFlag)
|
|
);
|
|
})
|
|
.map(config => {
|
|
return config.count();
|
|
})
|
|
.reduce((sum, count) => sum + count, 0);
|
|
});
|
|
|
|
const activeTabIndex = computed(() => {
|
|
const index = tabs.value.findIndex(tab => tab.key === selectedTab.value);
|
|
return index >= 0 ? index : 0;
|
|
});
|
|
|
|
const isFetchingAny = computed(() => {
|
|
const { contact, message, conversation, article, isFetching } = uiFlags.value;
|
|
return (
|
|
isFetching ||
|
|
contact.isFetching ||
|
|
message.isFetching ||
|
|
conversation.isFetching ||
|
|
article.isFetching
|
|
);
|
|
});
|
|
|
|
const showEmptySearchResults = computed(
|
|
() =>
|
|
totalSearchResultsCount.value === 0 &&
|
|
uiFlags.value.isSearchCompleted &&
|
|
isSelectedTabAll.value &&
|
|
!isFetchingAny.value &&
|
|
query.value
|
|
);
|
|
|
|
const showResultsSection = computed(
|
|
() =>
|
|
(uiFlags.value.isSearchCompleted && totalSearchResultsCount.value !== 0) ||
|
|
isFetchingAny.value ||
|
|
(!isSelectedTabAll.value && query.value && !isFetchingAny.value)
|
|
);
|
|
|
|
const showLoadMore = computed(() => {
|
|
if (!query.value || isFetchingAny.value || selectedTab.value === 'all')
|
|
return false;
|
|
|
|
const records = {
|
|
contacts: mappedContacts.value,
|
|
conversations: mappedConversations.value,
|
|
messages: mappedMessages.value,
|
|
articles: mappedArticles.value,
|
|
}[selectedTab.value];
|
|
|
|
return (
|
|
records?.length > 0 &&
|
|
records.length === pages.value[selectedTab.value] * PER_PAGE
|
|
);
|
|
});
|
|
|
|
const showViewMore = computed(() => ({
|
|
// Hide view more button if the number of records is less than 5
|
|
contacts: mappedContacts.value?.length > 5 && isSelectedTabAll.value,
|
|
conversations:
|
|
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
|
|
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
|
|
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
|
|
}));
|
|
|
|
const filters = ref({
|
|
from: null,
|
|
in: null,
|
|
dateRange: { type: null, from: null, to: null },
|
|
});
|
|
|
|
const clearSearchResult = () => {
|
|
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
|
|
store.dispatch('conversationSearch/clearSearchResults');
|
|
};
|
|
|
|
const buildSearchPayload = (basePayload = {}, searchType = 'message') => {
|
|
const payload = { ...basePayload };
|
|
|
|
// Only include filters if advanced search is enabled
|
|
if (isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)) {
|
|
// Date filters apply to all search types
|
|
if (filters.value.dateRange.from) {
|
|
payload.since = filters.value.dateRange.from;
|
|
}
|
|
if (filters.value.dateRange.to) {
|
|
payload.until = filters.value.dateRange.to;
|
|
}
|
|
|
|
// Only messages support 'from' and 'inboxId' filters
|
|
if (searchType === 'message') {
|
|
if (filters.value.from) payload.from = filters.value.from;
|
|
if (filters.value.in) payload.inboxId = filters.value.in;
|
|
}
|
|
}
|
|
|
|
return payload;
|
|
};
|
|
|
|
const updateURL = () => {
|
|
const params = {
|
|
accountId: route.params.accountId,
|
|
...(selectedTab.value !== 'all' && { tab: selectedTab.value }),
|
|
};
|
|
|
|
const queryParams = {
|
|
...(query.value?.trim() && { q: query.value.trim() }),
|
|
...generateURLParams(
|
|
filters.value,
|
|
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
|
),
|
|
};
|
|
|
|
router.replace({ name: 'search', params, query: queryParams });
|
|
};
|
|
|
|
const onSearch = q => {
|
|
query.value = q;
|
|
clearSearchResult();
|
|
updateURL();
|
|
if (!q) return;
|
|
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
|
|
|
|
const searchPayload = buildSearchPayload({ q, page: 1 });
|
|
store.dispatch('conversationSearch/fullSearch', searchPayload);
|
|
};
|
|
|
|
const onFilterChange = () => {
|
|
onSearch(query.value);
|
|
};
|
|
|
|
const onBack = () => {
|
|
if (window.history.length > 2) {
|
|
router.go(-1);
|
|
} else {
|
|
router.push({ name: 'home' });
|
|
}
|
|
clearSearchResult();
|
|
};
|
|
|
|
const loadMore = () => {
|
|
const SEARCH_ACTIONS = {
|
|
contacts: 'conversationSearch/contactSearch',
|
|
conversations: 'conversationSearch/conversationSearch',
|
|
messages: 'conversationSearch/messageSearch',
|
|
articles: 'conversationSearch/articleSearch',
|
|
};
|
|
|
|
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
|
|
|
|
const tab = selectedTab.value;
|
|
pages.value[tab] += 1;
|
|
|
|
const payload = buildSearchPayload(
|
|
{ q: query.value, page: pages.value[tab] },
|
|
tab
|
|
);
|
|
|
|
store.dispatch(SEARCH_ACTIONS[tab], payload);
|
|
};
|
|
|
|
const onTabChange = tab => {
|
|
selectedTab.value = tab;
|
|
updateURL();
|
|
};
|
|
|
|
onMounted(() => {
|
|
store.dispatch('conversationSearch/clearSearchResults');
|
|
store.dispatch('agents/get');
|
|
});
|
|
|
|
// Wait for the account before restoring URL filters: the ADVANCED_SEARCH flag
|
|
// derives from account.features (loaded async), and reading it too early strips
|
|
// the filter params from the URL. `immediate` covers the already-loaded case.
|
|
watch(
|
|
() => currentAccount.value?.id,
|
|
id => {
|
|
if (!id) return;
|
|
filters.value = parseURLParams(
|
|
route.query,
|
|
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
|
|
);
|
|
if (route.query.q) {
|
|
onSearch(route.query.q);
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
onUnmounted(() => {
|
|
query.value = '';
|
|
store.dispatch('conversationSearch/clearSearchResults');
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col w-full h-full bg-n-surface-1">
|
|
<div class="flex w-full p-4">
|
|
<NextButton
|
|
:label="t('GENERAL_SETTINGS.BACK')"
|
|
icon="i-lucide-chevron-left"
|
|
faded
|
|
primary
|
|
sm
|
|
@click="onBack"
|
|
/>
|
|
</div>
|
|
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
|
|
<div class="w-full max-w-5xl mx-auto z-30">
|
|
<div class="flex flex-col w-full px-4">
|
|
<SearchHeader
|
|
v-model:filters="filters"
|
|
:initial-query="query"
|
|
@search="onSearch"
|
|
@filter-change="onFilterChange"
|
|
/>
|
|
<SearchTabs
|
|
v-if="query"
|
|
:tabs="tabs"
|
|
:selected-tab="activeTabIndex"
|
|
@tab-change="onTabChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="flex-grow w-full h-full overflow-y-auto">
|
|
<div class="w-full max-w-5xl mx-auto px-4 pb-6">
|
|
<div v-if="showResultsSection">
|
|
<Policy
|
|
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
|
|
class="flex flex-col justify-center"
|
|
>
|
|
<SearchResultContactsList
|
|
v-if="filterContacts"
|
|
:is-fetching="uiFlags.contact.isFetching"
|
|
:contacts="contacts"
|
|
:query="query"
|
|
:show-title="isSelectedTabAll"
|
|
class="mt-0.5"
|
|
/>
|
|
<NextButton
|
|
v-if="showViewMore.contacts"
|
|
:label="t(`SEARCH.VIEW_MORE`)"
|
|
icon="i-lucide-eye"
|
|
slate
|
|
sm
|
|
outline
|
|
@click="selectedTab = 'contacts'"
|
|
/>
|
|
</Policy>
|
|
|
|
<Policy
|
|
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
|
|
class="flex flex-col justify-center"
|
|
>
|
|
<SearchResultMessagesList
|
|
v-if="filterMessages"
|
|
:is-fetching="uiFlags.message.isFetching"
|
|
:messages="messages"
|
|
:query="query"
|
|
:show-title="isSelectedTabAll"
|
|
:class="searchResultSectionClass"
|
|
/>
|
|
<NextButton
|
|
v-if="showViewMore.messages"
|
|
:label="t(`SEARCH.VIEW_MORE`)"
|
|
icon="i-lucide-eye"
|
|
slate
|
|
sm
|
|
outline
|
|
@click="selectedTab = 'messages'"
|
|
/>
|
|
</Policy>
|
|
|
|
<Policy
|
|
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
|
|
class="flex flex-col justify-center"
|
|
>
|
|
<SearchResultConversationsList
|
|
v-if="filterConversations"
|
|
:is-fetching="uiFlags.conversation.isFetching"
|
|
:conversations="conversations"
|
|
:query="query"
|
|
:show-title="isSelectedTabAll"
|
|
:class="searchResultSectionClass"
|
|
/>
|
|
<NextButton
|
|
v-if="showViewMore.conversations"
|
|
:label="t(`SEARCH.VIEW_MORE`)"
|
|
icon="i-lucide-eye"
|
|
slate
|
|
sm
|
|
outline
|
|
@click="selectedTab = 'conversations'"
|
|
/>
|
|
</Policy>
|
|
|
|
<Policy
|
|
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
|
|
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
|
|
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
|
|
class="flex flex-col justify-center"
|
|
>
|
|
<SearchResultArticlesList
|
|
v-if="filterArticles"
|
|
:is-fetching="uiFlags.article.isFetching"
|
|
:articles="articles"
|
|
:query="query"
|
|
:show-title="isSelectedTabAll"
|
|
:class="searchResultSectionClass"
|
|
/>
|
|
<NextButton
|
|
v-if="showViewMore.articles"
|
|
:label="t(`SEARCH.VIEW_MORE`)"
|
|
icon="i-lucide-eye"
|
|
slate
|
|
sm
|
|
outline
|
|
@click="selectedTab = 'articles'"
|
|
/>
|
|
</Policy>
|
|
|
|
<div v-if="showLoadMore" class="flex justify-center mt-3 mb-6">
|
|
<NextButton
|
|
v-if="!isSelectedTabAll"
|
|
:label="t(`SEARCH.LOAD_MORE`)"
|
|
icon="i-lucide-cloud-download"
|
|
slate
|
|
sm
|
|
faded
|
|
@click="loadMore"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-else-if="showEmptySearchResults"
|
|
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
|
|
>
|
|
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
|
|
<p class="m-2 text-center text-n-slate-11">
|
|
{{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
|
|
</p>
|
|
</div>
|
|
<div
|
|
v-else-if="!query"
|
|
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
|
|
>
|
|
<p class="text-center margin-bottom-0">
|
|
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
|
|
</p>
|
|
<p class="m-2 text-center text-n-slate-11">
|
|
{{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|