mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 11:37:58 +00:00
feat: Search improvements for conversations (#6645)
* feat: Shows search as a popover * Reverts search from popover to page * Fixes review comments on usability * Fixes keyboard navigation issues
This commit is contained in:
committed by
GitHub
parent
da76537011
commit
cae3ac94cd
@@ -14,7 +14,8 @@
|
|||||||
"EMPTY_STATE": "No %{item} found for query '%{query}'",
|
"EMPTY_STATE": "No %{item} found for query '%{query}'",
|
||||||
"EMPTY_STATE_FULL": "No results found for query '%{query}'",
|
"EMPTY_STATE_FULL": "No results found for query '%{query}'",
|
||||||
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||||
"INPUT_PLACEHOLDER": "Search message content, contact name, email or phone or conversations",
|
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
|
||||||
|
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
|
||||||
"BOT_LABEL": "Bot",
|
"BOT_LABEL": "Bot",
|
||||||
"READ_MORE": "Read more",
|
"READ_MORE": "Read more",
|
||||||
"WROTE": "wrote:"
|
"WROTE": "wrote:"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="input-container">
|
<div class="input-container" :class="{ 'is-focused': isInputFocused }">
|
||||||
<div class="icon-container">
|
<div class="icon-container">
|
||||||
<fluent-icon icon="search" class="icon" aria-hidden="true" />
|
<fluent-icon icon="search" class="icon" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
@@ -8,11 +8,15 @@
|
|||||||
type="search"
|
type="search"
|
||||||
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
|
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
|
||||||
:value="searchQuery"
|
:value="searchQuery"
|
||||||
|
@focus="onFocus"
|
||||||
|
@blur="onBlur"
|
||||||
@input="debounceSearch"
|
@input="debounceSearch"
|
||||||
/>
|
/>
|
||||||
<div class="key-binding">
|
<woot-label
|
||||||
<span>{{ $t('SEARCH.PLACEHOLDER_KEYBINDING') }}</span>
|
:title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"
|
||||||
</div>
|
:show-close="false"
|
||||||
|
small
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -21,6 +25,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
|
isInputFocused: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -35,6 +40,12 @@ export default {
|
|||||||
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.$refs.searchInput.focus();
|
this.$refs.searchInput.focus();
|
||||||
|
} else if (
|
||||||
|
e.key === 'Escape' &&
|
||||||
|
document.activeElement.tagName === 'INPUT'
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.$refs.searchInput.blur();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
debounceSearch(e) {
|
debounceSearch(e) {
|
||||||
@@ -43,56 +54,50 @@ export default {
|
|||||||
this.debounce = setTimeout(async () => {
|
this.debounce = setTimeout(async () => {
|
||||||
if (this.searchQuery.length > 2 || this.searchQuery.match(/^[0-9]+$/)) {
|
if (this.searchQuery.length > 2 || this.searchQuery.match(/^[0-9]+$/)) {
|
||||||
this.$emit('search', this.searchQuery);
|
this.$emit('search', this.searchQuery);
|
||||||
|
} else {
|
||||||
|
this.$emit('search', '');
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
|
onFocus() {
|
||||||
|
this.isInputFocused = true;
|
||||||
|
},
|
||||||
|
onBlur() {
|
||||||
|
this.isInputFocused = false;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import 'app/javascript/dashboard/assets/scss/_mixins.scss';
|
||||||
.input-container {
|
.input-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: var(--border-radius-normal);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-small) var(--space-normal);
|
||||||
|
border-bottom: 1px solid var(--s-100);
|
||||||
|
transition: border-bottom 0.2s ease-in-out;
|
||||||
|
|
||||||
input[type='search'] {
|
input[type='search'] {
|
||||||
|
@include ghost-input;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-left: calc(var(--space-large) + var(--space-small));
|
margin: 0;
|
||||||
margin-bottom: 0;
|
}
|
||||||
padding-right: var(--space-mega);
|
|
||||||
&:focus {
|
&.is-focused {
|
||||||
.icon {
|
border-bottom: 1px solid var(--w-100);
|
||||||
color: var(--w-500) !important;
|
|
||||||
}
|
.icon {
|
||||||
|
color: var(--w-400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.icon-container {
|
.icon-container {
|
||||||
padding-left: var(--space-small);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
position: absolute;
|
|
||||||
.icon {
|
.icon {
|
||||||
color: var(--s-400);
|
color: var(--s-400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.key-binding {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: var(--space-small) var(--space-small) 0 var(--space-small);
|
|
||||||
span {
|
|
||||||
color: var(--s-400);
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
font-size: calc(var(--space-slab) + var(--space-micro));
|
|
||||||
padding: 0 var(--space-small);
|
|
||||||
border: 1px solid var(--s-100);
|
|
||||||
border-radius: var(--border-radius-normal);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="search-input-box">
|
||||||
|
<woot-button
|
||||||
|
class="hollow"
|
||||||
|
size="small"
|
||||||
|
color-scheme="secondary"
|
||||||
|
is-expanded
|
||||||
|
>
|
||||||
|
<div class="search-input">
|
||||||
|
<fluent-icon icon="search" size="14px" class="search--icon" />
|
||||||
|
<span class="text-ellipsis search-placeholder">
|
||||||
|
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</woot-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
tabs: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
methods: {},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-input-box {
|
||||||
|
padding: var(--space-small);
|
||||||
|
}
|
||||||
|
.search--icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--s-500);
|
||||||
|
}
|
||||||
|
.search-placeholder {
|
||||||
|
color: var(--s-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-smaller);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -64,7 +64,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contact-details {
|
.contact-details {
|
||||||
margin-left: var(--space-normal);
|
margin-left: var(--space-small);
|
||||||
}
|
}
|
||||||
.name {
|
.name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default {
|
|||||||
margin-left: var(--space-smaller);
|
margin-left: var(--space-smaller);
|
||||||
}
|
}
|
||||||
.conversation-details {
|
.conversation-details {
|
||||||
margin-left: var(--space-normal);
|
margin-left: var(--space-small);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<section>
|
<section class="result-section">
|
||||||
<div class="label-container">
|
<div class="header">
|
||||||
<h3 class="text-block-title">{{ title }}</h3>
|
<h3 class="text-block-title">{{ title }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
@@ -38,17 +38,21 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.result-section {
|
||||||
|
margin-bottom: var(--space-normal);
|
||||||
|
}
|
||||||
.search-list {
|
.search-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--spacing-normal) 0;
|
padding: var(--spacing-normal) 0;
|
||||||
}
|
}
|
||||||
.label-container {
|
.header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding: var(--space-small) 0;
|
padding: var(--space-small);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
|
margin-bottom: var(--space-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -56,6 +60,7 @@ export default {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: var(--space-medium) var(--space-normal);
|
padding: var(--space-medium) var(--space-normal);
|
||||||
|
margin: 0 var(--space-small);
|
||||||
background: var(--s-25);
|
background: var(--s-25);
|
||||||
border-radius: var(--border-radius-medium);
|
border-radius: var(--border-radius-medium);
|
||||||
.icon {
|
.icon {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.tab-container {
|
.tab-container {
|
||||||
margin-left: var(--space-minus-normal);
|
margin-top: var(--space-smaller);
|
||||||
margin-top: var(--space-small);
|
border-bottom: 1px solid var(--s-50);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,46 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="search-root">
|
<div class="width-100">
|
||||||
<woot-button
|
<div class="page-header">
|
||||||
color-scheme="secondary"
|
<woot-button
|
||||||
size="large"
|
icon="chevron-left"
|
||||||
icon="dismiss"
|
variant="smooth"
|
||||||
variant="smooth"
|
size="small "
|
||||||
class="modal--close"
|
class="back-button"
|
||||||
@click="onBack"
|
@click="onBack"
|
||||||
/>
|
>
|
||||||
<header>
|
{{ $t('GENERAL_SETTINGS.BACK') }}
|
||||||
<search-header @search="search" />
|
</woot-button>
|
||||||
<search-tabs :tabs="tabs" @tab-change="tab => (selectedTab = tab)" />
|
</div>
|
||||||
</header>
|
<section class="search-root">
|
||||||
<div class="search-results">
|
<header>
|
||||||
<woot-loading-state v-if="uiFlags.isFetching" :message="'Searching'" />
|
<search-header @search="onSearch" />
|
||||||
<div v-else>
|
<search-tabs
|
||||||
<div v-if="all.length">
|
v-if="query"
|
||||||
<search-result-contacts-list
|
:tabs="tabs"
|
||||||
v-if="filterContacts"
|
@tab-change="tab => (selectedTab = tab)"
|
||||||
:contacts="contacts"
|
/>
|
||||||
:query="query"
|
</header>
|
||||||
/>
|
<div class="search-results">
|
||||||
<search-result-messages-list
|
<woot-loading-state v-if="uiFlags.isFetching" :message="'Searching'" />
|
||||||
v-if="filterMessages"
|
<div v-else>
|
||||||
:messages="messages"
|
<div v-if="all.length">
|
||||||
:query="query"
|
<search-result-contacts-list
|
||||||
/>
|
v-if="filterContacts"
|
||||||
<search-result-conversations-list
|
:contacts="contacts"
|
||||||
v-if="filterConversations"
|
:query="query"
|
||||||
:conversations="conversations"
|
/>
|
||||||
:query="query"
|
<search-result-messages-list
|
||||||
/>
|
v-if="filterMessages"
|
||||||
</div>
|
:messages="messages"
|
||||||
<div v-else-if="showEmptySearchResults" class="empty">
|
:query="query"
|
||||||
<fluent-icon icon="info" size="16px" class="icon" />
|
/>
|
||||||
<p class="empty-state__text">
|
<search-result-conversations-list
|
||||||
{{ $t('SEARCH.EMPTY_STATE_FULL', { query }) }}
|
v-if="filterConversations"
|
||||||
</p>
|
:conversations="conversations"
|
||||||
|
:query="query"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="showEmptySearchResults && !all.length" class="empty">
|
||||||
|
<fluent-icon icon="info" size="16px" class="icon" />
|
||||||
|
<p class="empty-state__text">
|
||||||
|
{{ $t('SEARCH.EMPTY_STATE_FULL', { query }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty text-center">
|
||||||
|
<p class="text-center">
|
||||||
|
<fluent-icon icon="search" size="24px" class="icon" />
|
||||||
|
</p>
|
||||||
|
<p class="empty-state__text">
|
||||||
|
{{ $t('SEARCH.EMPTY_STATE_DEFAULT') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -51,6 +68,7 @@ import SearchResultMessagesList from './SearchResultMessagesList.vue';
|
|||||||
import SearchResultContactsList from './SearchResultContactsList.vue';
|
import SearchResultContactsList from './SearchResultContactsList.vue';
|
||||||
import { isEmptyObject } from 'dashboard/helper/commons.js';
|
import { isEmptyObject } from 'dashboard/helper/commons.js';
|
||||||
|
|
||||||
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
export default {
|
export default {
|
||||||
@@ -61,12 +79,14 @@ export default {
|
|||||||
SearchResultConversationsList,
|
SearchResultConversationsList,
|
||||||
SearchResultMessagesList,
|
SearchResultMessagesList,
|
||||||
},
|
},
|
||||||
|
mixins: [clickaway],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selectedTab: 'all',
|
selectedTab: 'all',
|
||||||
query: '',
|
query: '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
fullSearchRecords: 'conversationSearch/getFullSearchRecords',
|
fullSearchRecords: 'conversationSearch/getFullSearchRecords',
|
||||||
@@ -147,48 +167,68 @@ export default {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.query = '';
|
||||||
|
this.$store.dispatch('conversationSearch/clearSearchResults');
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$store.dispatch('conversationSearch/clearSearchResults');
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
search(q) {
|
onSearch(q) {
|
||||||
this.query = q;
|
this.query = q;
|
||||||
|
if (!q) {
|
||||||
|
this.$store.dispatch('conversationSearch/clearSearchResults');
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.$track(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
|
this.$track(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
|
||||||
this.$store.dispatch('conversationSearch/fullSearch', { q });
|
this.$store.dispatch('conversationSearch/fullSearch', { q });
|
||||||
},
|
},
|
||||||
onBack() {
|
onBack() {
|
||||||
this.$router.push({ name: 'home' });
|
if (window.history.length > 2) {
|
||||||
|
this.$router.go(-1);
|
||||||
|
} else {
|
||||||
|
this.$router.push({ name: 'home' });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
padding: var(--space-normal);
|
||||||
|
}
|
||||||
.search-root {
|
.search-root {
|
||||||
max-width: 800px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 100%;
|
max-width: 64rem;
|
||||||
|
min-height: 48rem;
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: var(--space-normal);
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--border-radius-large);
|
||||||
|
margin-top: var(--space-large);
|
||||||
|
border-top: 1px solid var(--s-25);
|
||||||
.search-results {
|
.search-results {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-top: var(--space-normal);
|
padding: 0 var(--space-small);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modal--close {
|
|
||||||
position: fixed;
|
|
||||||
right: var(--space-small);
|
|
||||||
top: var(--space-small);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: var(--space-medium) var(--space-normal);
|
padding: var(--space-medium) var(--space-normal);
|
||||||
background: var(--s-25);
|
|
||||||
border-radius: var(--border-radius-medium);
|
border-radius: var(--border-radius-medium);
|
||||||
.icon {
|
.icon {
|
||||||
color: var(--s-500);
|
color: var(--s-500);
|
||||||
@@ -196,7 +236,7 @@ export default {
|
|||||||
.empty-state__text {
|
.empty-state__text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--s-500);
|
color: var(--s-500);
|
||||||
margin: 0 var(--space-small);
|
margin: var(--space-small);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<div class="search" :class="{ 'is-active': showSearchBox }">
|
<div class="search" :class="{ 'is-active': showSearchBox }">
|
||||||
<woot-sidemenu-icon />
|
<woot-sidemenu-icon />
|
||||||
<router-link :to="searchUrl" class="search--link" replace>
|
<router-link :to="searchUrl" class="search--link">
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<fluent-icon icon="search" class="search--icon" size="16" />
|
<fluent-icon icon="search" class="search--icon" size="16" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export const actions = {
|
|||||||
commit(types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false });
|
commit(types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async clearSearchResults({ commit }) {
|
||||||
|
commit(types.FULL_SEARCH_SET, {});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
|||||||
Reference in New Issue
Block a user