chore: Adds URL-based search and tab selection (#12663)

# Pull Request Template

## Description

This PR enables URL-based search and tab selection, allowing search
queries and active tabs to persist in the URL for easy sharing.

Fixes
[CW-5766](https://linear.app/chatwoot/issue/CW-5766/cannot-impersonate-an-account),
https://github.com/chatwoot/chatwoot/issues/12623

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/422a1d61f3fe4278a88e352ef98d2b78?sid=35fabee7-652f-4e17-83bd-e066a3bb804c

## 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
This commit is contained in:
Sivin Varghese
2025-10-27 11:17:04 +05:30
committed by GitHub
parent 5891fd6f49
commit f726dc2419
3 changed files with 58 additions and 8 deletions

View File

@@ -1,10 +1,17 @@
<script setup> <script setup>
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue'; import { ref, useTemplateRef, onMounted, onUnmounted, watch } from 'vue';
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
const props = defineProps({
initialQuery: {
type: String,
default: '',
},
});
const emit = defineEmits(['search']); const emit = defineEmits(['search']);
const searchQuery = ref(''); const searchQuery = ref(props.initialQuery);
const isInputFocused = ref(false); const isInputFocused = ref(false);
const searchInput = useTemplateRef('searchInput'); const searchInput = useTemplateRef('searchInput');
@@ -38,6 +45,16 @@ const onBlur = () => {
isInputFocused.value = false; isInputFocused.value = false;
}; };
watch(
() => props.initialQuery,
newValue => {
if (searchQuery.value !== newValue) {
searchQuery.value = newValue;
}
},
{ immediate: true }
);
onMounted(() => { onMounted(() => {
searchInput.value.focus(); searchInput.value.focus();
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js'; import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { import {
@@ -24,12 +24,13 @@ import SearchResultContactsList from './SearchResultContactsList.vue';
import SearchResultArticlesList from './SearchResultArticlesList.vue'; import SearchResultArticlesList from './SearchResultArticlesList.vue';
const router = useRouter(); const router = useRouter();
const route = useRoute();
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
const PER_PAGE = 15; // Results per page const PER_PAGE = 15; // Results per page
const selectedTab = ref('all'); const selectedTab = ref(route.params.tab || 'all');
const query = ref(''); const query = ref(route.query.q || '');
const pages = ref({ const pages = ref({
contacts: 1, contacts: 1,
conversations: 1, conversations: 1,
@@ -231,9 +232,31 @@ const clearSearchResult = () => {
store.dispatch('conversationSearch/clearSearchResults'); store.dispatch('conversationSearch/clearSearchResults');
}; };
const updateURL = () => {
// Update route with tab as URL parameter and query as query parameter
const params = { accountId: route.params.accountId };
const queryParams = {};
// Only add tab param if not 'all'
if (selectedTab.value !== 'all') {
params.tab = selectedTab.value;
}
if (query.value?.trim()) {
queryParams.q = query.value.trim();
}
router.replace({
name: 'search',
params,
query: queryParams,
});
};
const onSearch = q => { const onSearch = q => {
query.value = q; query.value = q;
clearSearchResult(); clearSearchResult();
updateURL();
if (!q) return; if (!q) return;
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION); useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
store.dispatch('conversationSearch/fullSearch', { q, page: 1 }); store.dispatch('conversationSearch/fullSearch', { q, page: 1 });
@@ -265,8 +288,18 @@ const loadMore = () => {
}); });
}; };
const onTabChange = tab => {
selectedTab.value = tab;
updateURL();
};
onMounted(() => { onMounted(() => {
store.dispatch('conversationSearch/clearSearchResults'); store.dispatch('conversationSearch/clearSearchResults');
// Auto-execute search if query parameter exists
if (route.query.q) {
onSearch(route.query.q);
}
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -290,12 +323,12 @@ onUnmounted(() => {
<section class="flex flex-col flex-grow w-full h-full overflow-hidden"> <section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-4xl mx-auto"> <div class="w-full max-w-4xl mx-auto">
<div class="flex flex-col w-full px-4"> <div class="flex flex-col w-full px-4">
<SearchHeader @search="onSearch" /> <SearchHeader :initial-query="query" @search="onSearch" />
<SearchTabs <SearchTabs
v-if="query" v-if="query"
:tabs="tabs" :tabs="tabs"
:selected-tab="activeTabIndex" :selected-tab="activeTabIndex"
@tab-change="tab => (selectedTab = tab)" @tab-change="onTabChange"
/> />
</div> </div>
</div> </div>

View File

@@ -10,7 +10,7 @@ import SearchView from './components/SearchView.vue';
export const routes = [ export const routes = [
{ {
path: frontendURL('accounts/:accountId/search'), path: frontendURL('accounts/:accountId/search/:tab?'),
name: 'search', name: 'search',
meta: { meta: {
permissions: [ permissions: [