mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-27 08:33:44 +00:00
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:
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user