feat: Revamps search to use new search API's (#6582)

* feat: Revamps search to use new search API's

* Fixes search result spacing

* Fixes message result

* Fixes issue with empty search results

* Remove console errors

* Remove console errors

* Fix console errors, canned responses

* Fixes message rendering on results

* Highlights search term

* Fixes html rendering for emails

* FIxes email rendering issues

* Removes extra spaces and line breaks

---------

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Nithin David Thomas
2023-03-03 20:58:21 +05:30
committed by GitHub
parent 2a385f377c
commit 88ed028a06
24 changed files with 896 additions and 142 deletions

View File

@@ -0,0 +1,18 @@
/* global axios */
import ApiClient from './ApiClient';
class SearchAPI extends ApiClient {
constructor() {
super('search', { accountScoped: true });
}
get({ q }) {
return axios.get(this.url, {
params: {
q,
},
});
}
}
export default new SearchAPI();

View File

@@ -31,7 +31,7 @@ export default {
line-height: var(--space-slab);
font-weight: var(--font-weight-medium);
background: none;
color: var(--s-500);
color: var(--s-600);
font-size: var(--font-size-mini);
margin: 0 var(--space-one);
}

View File

@@ -887,6 +887,11 @@ export default {
toggleTyping(status) {
const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate;
if (!conversationId) {
return;
}
this.$store.dispatch('conversationTypingStatus/toggleTyping', {
status,
conversationId,

View File

@@ -5,7 +5,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
INSERTED_A_VARIABLE: 'Inserted a variable',
USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
});

View File

@@ -22,6 +22,7 @@ import login from './login.json';
import macros from './macros.json';
import report from './report.json';
import resetPassword from './resetPassword.json';
import search from './search.json';
import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
@@ -53,6 +54,7 @@ export default {
...macros,
...report,
...resetPassword,
...search,
...setNewPassword,
...settings,
...signup,

View File

@@ -0,0 +1,22 @@
{
"SEARCH": {
"TABS": {
"ALL": "All",
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"SECTION": {
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"EMPTY_STATE": "No %{item} found for query '%{query}'",
"EMPTY_STATE_FULL": "No results found for query '%{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Search message content, contact name, email or phone or conversations",
"BOT_LABEL": "Bot",
"READ_MORE": "Read more",
"WROTE": "wrote:"
}
}

View File

@@ -0,0 +1,110 @@
<template>
<blockquote ref="messageContainer" class="message">
<p class="header">
<strong class="author">
{{ author }}
</strong>
{{ $t('SEARCH.WROTE') }}
</p>
<read-more :shrink="isOverflowing" @expand="isOverflowing = false">
<div v-dompurify-html="prepareContent(content)" class="message-content" />
</read-more>
</blockquote>
</template>
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import ReadMore from './ReadMore.vue';
export default {
components: {
ReadMore,
},
mixins: [messageFormatterMixin],
props: {
author: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
},
data() {
return {
isOverflowing: false,
};
},
computed: {
messageContent() {
return this.formatMessage(this.content);
},
},
mounted() {
this.$nextTick(() => {
const wrap = this.$refs.messageContainer;
const message = wrap.querySelector('.message-content');
this.isOverflowing = message.offsetHeight > 150;
});
},
methods: {
escapeHtml(html) {
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
},
prepareContent(content = '') {
const escapedText = this.escapeHtml(content);
const plainTextContent = this.getPlainText(escapedText);
const escapedSearchTerm = this.escapeRegExp(this.searchTerm);
return plainTextContent
.replace(
new RegExp(`(${escapedSearchTerm})`, 'ig'),
'<span class="searchkey--highlight">$1</span>'
)
.replace(/\s{2,}|\n|\r/g, ' ');
},
// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
},
};
</script>
<style scoped lang="scss">
.message {
border-color: var(--s-100);
border-width: var(--space-micro);
padding: 0 var(--space-small);
margin-top: var(--space-small);
}
.message-content::v-deep p,
.message-content::v-deep li::marker {
color: var(--s-700);
margin-bottom: var(--space-smaller);
}
.author {
color: var(--s-700);
}
.header {
color: var(--s-500);
margin-bottom: var(--space-smaller);
}
.message-content {
overflow-wrap: break-word;
}
.message-content::v-deep .searchkey--highlight {
color: var(--w-600);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-small);
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="read-more">
<div ref="content" :class="{ 'shrink-container': shrink }">
<slot />
<woot-button
v-if="shrink"
size="tiny"
variant="smooth"
color-scheme="primary"
class="read-more-button"
@click.prevent="$emit('expand')"
>
{{ $t('SEARCH.READ_MORE') }}
</woot-button>
</div>
</div>
</template>
<script>
export default {
props: {
shrink: {
type: Boolean,
default: false,
},
},
};
</script>
<style lang="scss" scoped>
.shrink-container {
max-height: 100px;
overflow: hidden;
position: relative;
}
.shrink-container::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #fff 100%);
z-index: 4;
}
.read-more-button {
max-width: max-content;
position: absolute;
bottom: var(--space-small);
left: 0;
right: 0;
margin: 0 auto;
z-index: 5;
box-shadow: var(--box-shadow);
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="input-container">
<div class="icon-container">
<fluent-icon icon="search" class="icon" aria-hidden="true" />
</div>
<input
ref="searchInput"
type="search"
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
:value="searchQuery"
@input="debounceSearch"
/>
<div class="key-binding">
<span>{{ $t('SEARCH.PLACEHOLDER_KEYBINDING') }}</span>
</div>
</div>
</template>
<script>
export default {
data() {
return {
searchQuery: '',
};
},
mounted() {
this.$refs.searchInput.focus();
document.addEventListener('keydown', this.handler);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handler);
},
methods: {
handler(e) {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
this.$refs.searchInput.focus();
}
},
debounceSearch(e) {
this.searchQuery = e.target.value;
clearTimeout(this.debounce);
this.debounce = setTimeout(async () => {
if (this.searchQuery.length > 2 || this.searchQuery.match(/^[0-9]+$/)) {
this.$emit('search', this.searchQuery);
}
}, 500);
},
},
};
</script>
<style lang="scss" scoped>
.input-container {
position: relative;
border-radius: var(--border-radius-normal);
input[type='search'] {
width: 100%;
padding-left: calc(var(--space-large) + var(--space-small));
margin-bottom: 0;
padding-right: var(--space-mega);
&:focus {
.icon {
color: var(--w-500) !important;
}
}
}
}
.icon-container {
padding-left: var(--space-small);
display: flex;
align-items: center;
top: 0;
bottom: 0;
left: 0;
position: absolute;
.icon {
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>

View File

@@ -19,8 +19,8 @@ import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
props: {
id: {
type: String,
default: '',
type: [String, Number],
default: 0,
},
email: {
type: String,
@@ -39,8 +39,8 @@ export default {
default: '',
},
accountId: {
type: String,
default: '',
type: [String, Number],
default: 0,
},
},
computed: {

View File

@@ -0,0 +1,49 @@
<template>
<search-result-section
:title="$t('SEARCH.SECTION.CONTACTS')"
:empty="!contacts.length"
:query="query"
>
<ul class="search-list">
<li v-for="contact in contacts" :key="contact.id">
<search-result-contact-item
:id="contact.id"
:name="contact.name"
:email="contact.email"
:phone="contact.phone_number"
:account-id="accountId"
:thumbnail="contact.thumbnail"
/>
</li>
</ul>
</search-result-section>
</template>
<script>
import { mapGetters } from 'vuex';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultContactItem from './SearchResultContactItem.vue';
export default {
components: {
SearchResultSection,
SearchResultContactItem,
},
props: {
contacts: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
},
};
</script>

View File

@@ -17,10 +17,10 @@
</div>
</div>
<div>
<span>{{ createdAtTime }}</span>
<span class="created-at">{{ createdAtTime }}</span>
</div>
</div>
<h5 class="text-block-title name">
<h5 v-if="name" class="text-block-title name">
<span class="pre-text">from:</span>
{{ name }}
</h5>
@@ -41,12 +41,8 @@ export default {
mixins: [timeMixin],
props: {
id: {
type: String,
default: '',
},
email: {
type: String,
default: '',
type: Number,
default: 0,
},
inbox: {
type: Object,
@@ -56,12 +52,8 @@ export default {
type: String,
default: '',
},
thumbnail: {
type: String,
default: '',
},
accountId: {
type: String,
type: [String, Number],
default: '',
},
createdAt: {
@@ -101,6 +93,7 @@ export default {
.icon-wrap {
width: var(--space-medium);
height: var(--space-medium);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -122,12 +115,13 @@ export default {
.conversation-details {
margin-left: var(--space-normal);
flex-grow: 1;
min-width: 0;
}
.conversation-id,
.name {
margin: 0;
}
.created-at,
.pre-text {
color: var(--s-600);
font-size: var(--font-size-mini);

View File

@@ -0,0 +1,47 @@
<template>
<search-result-section
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
:empty="!conversations.length"
:query="query"
>
<ul class="search-list">
<li v-for="conversation in conversations" :key="conversation.id">
<search-result-conversation-item
:id="conversation.id"
:name="conversation.contact.name"
:account-id="accountId"
:inbox="conversation.inbox"
:created-at="conversation.created_at"
/>
</li>
</ul>
</search-result-section>
</template>
<script>
import { mapGetters } from 'vuex';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
export default {
components: {
SearchResultSection,
SearchResultConversationItem,
},
props: {
conversations: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
},
};
</script>

View File

@@ -0,0 +1,61 @@
<template>
<search-result-section
:title="$t('SEARCH.SECTION.MESSAGES')"
:empty="!messages.length"
:query="query"
>
<ul class="search-list">
<li v-for="message in messages" :key="message.id">
<search-result-conversation-item
:id="message.conversation_id"
:account-id="accountId"
:inbox="message.inbox"
:created-at="message.created_at"
>
<message-content
:author="getName(message)"
:content="message.content"
:search-term="query"
/>
</search-result-conversation-item>
</li>
</ul>
</search-result-section>
</template>
<script>
import { mapGetters } from 'vuex';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
import SearchResultSection from './SearchResultSection.vue';
import MessageContent from './MessageContent';
export default {
components: {
SearchResultConversationItem,
SearchResultSection,
MessageContent,
},
props: {
messages: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
},
methods: {
getName(message) {
return message && message.sender && message.sender.name
? message.sender.name
: this.$t('SEARCH.BOT_LABEL');
},
},
};
</script>

View File

@@ -0,0 +1,70 @@
<template>
<section>
<div class="label-container">
<h3 class="text-block-title">{{ title }}</h3>
</div>
<slot />
<div v-if="empty" class="empty">
<fluent-icon icon="info" size="16px" class="icon" />
<p class="empty-state__text">
{{ $t('SEARCH.EMPTY_STATE', { item: titleCase, query }) }}
</p>
</div>
</section>
</template>
<script>
export default {
props: {
title: {
type: String,
default: '',
},
empty: {
type: Boolean,
default: false,
},
query: {
type: String,
default: '',
},
},
computed: {
titleCase() {
return this.title.toLowerCase();
},
},
};
</script>
<style scoped lang="scss">
.search-list {
list-style: none;
margin: 0;
padding: var(--spacing-normal) 0;
}
.label-container {
position: sticky;
top: 0;
padding: var(--space-small) 0;
z-index: 50;
background: var(--white);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-medium) var(--space-normal);
background: var(--s-25);
border-radius: var(--border-radius-medium);
.icon {
color: var(--s-500);
}
.empty-state__text {
text-align: center;
color: var(--s-500);
margin: 0 var(--space-small);
}
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="tab-container">
<woot-tabs :index="activeTab" :border="false" @change="onTabChange">
<woot-tabs-item
v-for="item in tabs"
:key="item.key"
:name="item.name"
:count="item.count"
/>
</woot-tabs>
</div>
</template>
<script>
export default {
props: {
tabs: {
type: Array,
default: () => [],
},
},
data() {
return {
activeTab: 0,
};
},
methods: {
onTabChange(index) {
this.activeTab = index;
this.$emit('tab-change', this.tabs[index].key);
},
},
};
</script>
<style lang="scss" scoped>
.tab-container {
margin-left: var(--space-minus-normal);
margin-top: var(--space-small);
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<section class="search-root">
<woot-button
color-scheme="secondary"
size="large"
icon="dismiss"
variant="smooth"
class="modal--close"
@click="onBack"
/>
<header>
<search-header @search="search" />
<search-tabs :tabs="tabs" @tab-change="tab => (selectedTab = tab)" />
</header>
<div class="search-results">
<woot-loading-state v-if="uiFlags.isFetching" :message="'Searching'" />
<div v-else>
<div v-if="all.length">
<search-result-contacts-list
v-if="filterContacts"
:contacts="contacts"
:query="query"
/>
<search-result-messages-list
v-if="filterMessages"
:messages="messages"
:query="query"
/>
<search-result-conversations-list
v-if="filterConversations"
:conversations="conversations"
:query="query"
/>
</div>
<div v-else-if="showEmptySearchResults" class="empty">
<fluent-icon icon="info" size="16px" class="icon" />
<p class="empty-state__text">
{{ $t('SEARCH.EMPTY_STATE_FULL', { query }) }}
</p>
</div>
</div>
</div>
</section>
</template>
<script>
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 { isEmptyObject } from 'dashboard/helper/commons.js';
import { mapGetters } from 'vuex';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
components: {
SearchHeader,
SearchTabs,
SearchResultContactsList,
SearchResultConversationsList,
SearchResultMessagesList,
},
data() {
return {
selectedTab: 'all',
query: '',
};
},
computed: {
...mapGetters({
fullSearchRecords: 'conversationSearch/getFullSearchRecords',
uiFlags: 'conversationSearch/getUIFlags',
}),
contacts() {
if (this.fullSearchRecords.contacts) {
return this.fullSearchRecords.contacts.map(contact => ({
...contact,
type: 'contact',
}));
}
return [];
},
conversations() {
if (this.fullSearchRecords.conversations) {
return this.fullSearchRecords.conversations.map(conversation => ({
...conversation,
type: 'conversation',
}));
}
return [];
},
messages() {
if (this.fullSearchRecords.messages) {
return this.fullSearchRecords.messages.map(message => ({
...message,
type: 'message',
}));
}
return [];
},
all() {
return [...this.contacts, ...this.conversations, ...this.messages];
},
filterContacts() {
return this.selectedTab === 'contacts' || this.selectedTab === 'all';
},
filterConversations() {
return this.selectedTab === 'conversations' || this.selectedTab === 'all';
},
filterMessages() {
return this.selectedTab === 'messages' || this.selectedTab === 'all';
},
totalSearchResultsCount() {
return (
this.contacts.length + this.conversations.length + this.messages.length
);
},
tabs() {
return [
{
key: 'all',
name: this.$t('SEARCH.TABS.ALL'),
count: this.totalSearchResultsCount,
},
{
key: 'contacts',
name: this.$t('SEARCH.TABS.CONTACTS'),
count: this.contacts.length,
},
{
key: 'conversations',
name: this.$t('SEARCH.TABS.CONVERSATIONS'),
count: this.conversations.length,
},
{
key: 'messages',
name: this.$t('SEARCH.TABS.MESSAGES'),
count: this.messages.length,
},
];
},
showEmptySearchResults() {
return (
this.totalSearchResultsCount === 0 &&
!isEmptyObject(this.fullSearchRecords)
);
},
},
methods: {
search(q) {
this.query = q;
this.$track(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
this.$store.dispatch('conversationSearch/fullSearch', { q });
},
onBack() {
this.$router.push({ name: 'home' });
},
},
};
</script>
<style lang="scss" scoped>
.search-root {
max-width: 800px;
width: 100%;
margin: 0 auto;
height: 100%;
display: flex;
position: relative;
padding: var(--space-normal);
flex-direction: column;
.search-results {
flex-grow: 1;
height: 100%;
overflow-y: auto;
margin-top: var(--space-normal);
}
}
.modal--close {
position: fixed;
right: var(--space-small);
top: var(--space-small);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-medium) var(--space-normal);
background: var(--s-25);
border-radius: var(--border-radius-medium);
.icon {
color: var(--s-500);
}
.empty-state__text {
text-align: center;
color: var(--s-500);
margin: 0 var(--space-small);
}
}
</style>

View File

@@ -0,0 +1,12 @@
/* eslint-disable storybook/default-exports */
import SearchView from './components/SearchView.vue';
import { frontendURL } from '../../helper/URLHelper';
export const routes = [
{
path: frontendURL('accounts/:accountId/search'),
name: 'search',
roles: ['administrator', 'agent'],
component: SearchView,
},
];

View File

@@ -0,0 +1,28 @@
import MessageContent from '../components/MessageContent.vue';
export default {
title: 'Components/Search/MessageContent',
component: MessageContent,
argTypes: {
content: {
defaultValue: '123',
control: {
type: 'text',
},
},
author: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { MessageContent },
template: '<message-content v-bind="$props"></message-content>',
});
export const MessageResultItem = Template.bind({});

View File

@@ -1,58 +1,18 @@
<template>
<div v-on-clickaway="closeSearch" class="search-wrap">
<div class="search-header--wrap" :class="{ 'is-active': showSearchBox }">
<woot-sidemenu-icon v-if="!showSearchBox" />
<div class="search-wrap">
<div class="search" :class="{ 'is-active': showSearchBox }">
<woot-sidemenu-icon />
<router-link :to="searchUrl" class="search--link" replace>
<div class="icon">
<fluent-icon icon="search" class="search--icon" size="16" />
</div>
<input
v-model="searchTerm"
class="search--input"
:placeholder="$t('CONVERSATION.SEARCH_MESSAGES')"
@focus="onSearch"
/>
</div>
<p class="search--label">{{ $t('CONVERSATION.SEARCH_MESSAGES') }}</p>
</router-link>
<switch-layout
v-if="!showSearchBox"
:is-on-expanded-layout="isOnExpandedLayout"
@toggle="$emit('toggle-conversation-layout')"
/>
</div>
<div v-if="showSearchBox" class="results-wrap">
<div class="show-results">
<div>
<div class="result-view">
<div class="result">
{{ $t('CONVERSATION.SEARCH.RESULT_TITLE') }}
<span v-if="resultsCount" class="message-counter">
({{ resultsCount }})
</span>
</div>
<div v-if="uiFlags.isFetching" class="search--activity-message">
<woot-spinner size="" />
{{ $t('CONVERSATION.SEARCH.LOADING_MESSAGE') }}
</div>
</div>
<div v-if="showSearchResult" class="search-results--container">
<result-item
v-for="conversation in conversations"
:key="conversation.messageId"
:conversation-id="conversation.id"
:user-name="conversation.contact.name"
:timestamp="conversation.created_at"
:messages="conversation.messages"
:search-term="searchTerm"
:inbox-name="conversation.inbox.name"
/>
</div>
<div v-else-if="showEmptyResult" class="search--activity-no-message">
{{ $t('CONVERSATION.SEARCH.NO_MATCHING_RESULTS') }}
</div>
</div>
</div>
</div>
</div>
</template>
@@ -60,15 +20,13 @@
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import timeMixin from '../../../../mixins/time';
import ResultItem from './ResultItem';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import SwitchLayout from './SwitchLayout.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
components: {
ResultItem,
SwitchLayout,
},
directives: {
focus: {
inserted(el) {
@@ -76,9 +34,7 @@ export default {
},
},
},
mixins: [timeMixin, messageFormatterMixin, clickaway],
props: {
isOnExpandedLayout: {
type: Boolean,
@@ -95,59 +51,10 @@ export default {
computed: {
...mapGetters({
conversations: 'conversationSearch/getConversations',
uiFlags: 'conversationSearch/getUIFlags',
currentPage: 'conversationPage/getCurrentPage',
accountId: 'getCurrentAccountId',
}),
resultsCount() {
return this.conversations.length;
},
showSearchResult() {
return (
this.searchTerm && this.conversations.length && !this.uiFlags.isFetching
);
},
showEmptyResult() {
return (
this.searchTerm &&
!this.conversations.length &&
!this.uiFlags.isFetching
);
},
},
watch: {
searchTerm(newValue) {
if (this.typingTimer) {
clearTimeout(this.typingTimer);
}
this.typingTimer = setTimeout(() => {
this.hasSearched = true;
this.$store.dispatch('conversationSearch/get', { q: newValue });
}, 1000);
},
currentPage() {
this.clearSearchTerm();
},
},
mounted() {
this.$store.dispatch('conversationSearch/get', { q: '' });
bus.$on('clearSearchInput', () => {
this.clearSearchTerm();
});
},
methods: {
onSearch() {
this.showSearchBox = true;
},
closeSearch() {
this.showSearchBox = false;
},
clearSearchTerm() {
this.searchTerm = '';
searchUrl() {
return frontendURL(`accounts/${this.accountId}/search`);
},
},
};
@@ -156,30 +63,34 @@ export default {
<style lang="scss" scoped>
.search-wrap {
position: relative;
padding: var(--space-one) var(--space-normal) var(--space-smaller)
var(--space-normal);
}
.search-header--wrap {
display: flex;
justify-content: space-between;
align-items: center;
min-height: var(--space-large);
}
.search {
display: flex;
flex: 1;
padding: 0 var(--space-smaller);
padding: 0;
border-bottom: 1px solid transparent;
padding: var(--space-one) var(--space-normal) var(--space-smaller)
var(--space-normal);
&:hover {
.search--icon {
.search--icon,
.search--label {
color: var(--w-500);
}
}
}
.search--link {
display: inline-flex;
align-items: center;
flex: 1;
}
.search--label {
color: var(--color-body);
margin-bottom: 0;
}
.search--input {
align-items: center;
border: 0;
@@ -215,7 +126,6 @@ input::placeholder {
width: 100%;
max-height: 70vh;
overflow: auto;
left: 0;
}
.show-results {

View File

@@ -1,6 +1,7 @@
import AppContainer from './Dashboard';
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
import { routes as contactRoutes } from './contacts/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { frontendURL } from '../../helper/URLHelper';
@@ -18,6 +19,7 @@ export default {
...conversation.routes,
...settings.routes,
...contactRoutes,
...searchRoutes,
...notificationRoutes,
],
},

View File

@@ -1,7 +1,8 @@
import ConversationAPI from '../../api/inbox/conversation';
import SearchAPI from '../../api/search';
import types from '../mutation-types';
export const initialState = {
records: [],
fullSearchRecords: {},
uiFlags: {
isFetching: false,
},
@@ -11,6 +12,9 @@ export const getters = {
getConversations(state) {
return state.records;
},
getFullSearchRecords(state) {
return state.fullSearchRecords;
},
getUIFlags(state) {
return state.uiFlags;
},
@@ -26,12 +30,29 @@ export const actions = {
try {
const {
data: { payload },
} = await ConversationAPI.search({ q });
} = await SearchAPI.get({ q });
commit(types.SEARCH_CONVERSATIONS_SET, payload);
} catch (error) {
// Ignore error
} finally {
commit(types.SEARCH_CONVERSATIONS_SET_UI_FLAG, { isFetching: false });
commit(types.SEARCH_CONVERSATIONS_SET_UI_FLAG, {
isFetching: false,
});
}
},
async fullSearch({ commit }, { q }) {
commit(types.FULL_SEARCH_SET, []);
if (!q) {
return;
}
commit(types.FULL_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.get({ q });
commit(types.FULL_SEARCH_SET, data.payload);
} catch (error) {
// Ignore error
} finally {
commit(types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
};
@@ -40,9 +61,15 @@ export const mutations = {
[types.SEARCH_CONVERSATIONS_SET](state, records) {
state.records = records;
},
[types.FULL_SEARCH_SET](state, records) {
state.fullSearchRecords = records;
},
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
state.uiFlags = { ...state.uiFlags, ...uiFlags };
},
[types.FULL_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags = { ...state.uiFlags, ...uiFlags };
},
};
export default {

View File

@@ -261,6 +261,9 @@ export default {
EDIT_MACRO: 'EDIT_MACRO',
DELETE_MACRO: 'DELETE_MACRO',
// Full Search
FULL_SEARCH_SET: 'FULL_SEARCH_SET',
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
SET_CONVERSATION_PARTICIPANTS_UI_FLAG:
'SET_CONVERSATION_PARTICIPANTS_UI_FLAG',
SET_CONVERSATION_PARTICIPANTS: 'SET_CONVERSATION_PARTICIPANTS',

View File

@@ -6,9 +6,7 @@ json.source_id message.source_id
json.inbox_id message.inbox_id
json.conversation_id message.conversation.try(:display_id)
json.created_at message.created_at.to_i
json.agent do
json.partial! 'agent', formats: [:json], agent: message.conversation.try(:assignee) if message.conversation.try(:assignee).present?
end
json.sender message.sender.push_event_data if message.sender
json.inbox do
json.partial! 'inbox', formats: [:json], inbox: message.inbox if message.inbox.present? && message.try(:inbox).present?
end