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:
parent
2a385f377c
commit
88ed028a06
18
app/javascript/dashboard/api/search.js
Normal file
18
app/javascript/dashboard/api/search.js
Normal 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();
|
||||||
@ -31,7 +31,7 @@ export default {
|
|||||||
line-height: var(--space-slab);
|
line-height: var(--space-slab);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--s-500);
|
color: var(--s-600);
|
||||||
font-size: var(--font-size-mini);
|
font-size: var(--font-size-mini);
|
||||||
margin: 0 var(--space-one);
|
margin: 0 var(--space-one);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -887,6 +887,11 @@ export default {
|
|||||||
toggleTyping(status) {
|
toggleTyping(status) {
|
||||||
const conversationId = this.currentChat.id;
|
const conversationId = this.currentChat.id;
|
||||||
const isPrivate = this.isPrivate;
|
const isPrivate = this.isPrivate;
|
||||||
|
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.$store.dispatch('conversationTypingStatus/toggleTyping', {
|
this.$store.dispatch('conversationTypingStatus/toggleTyping', {
|
||||||
status,
|
status,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
|
|||||||
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
|
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
|
||||||
INSERTED_A_VARIABLE: 'Inserted a variable',
|
INSERTED_A_VARIABLE: 'Inserted a variable',
|
||||||
USED_MENTIONS: 'Used mentions',
|
USED_MENTIONS: 'Used mentions',
|
||||||
|
SEARCH_CONVERSATION: 'Searched conversations',
|
||||||
APPLY_FILTER: 'Applied filters in the conversation list',
|
APPLY_FILTER: 'Applied filters in the conversation list',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import login from './login.json';
|
|||||||
import macros from './macros.json';
|
import macros from './macros.json';
|
||||||
import report from './report.json';
|
import report from './report.json';
|
||||||
import resetPassword from './resetPassword.json';
|
import resetPassword from './resetPassword.json';
|
||||||
|
import search from './search.json';
|
||||||
import setNewPassword from './setNewPassword.json';
|
import setNewPassword from './setNewPassword.json';
|
||||||
import settings from './settings.json';
|
import settings from './settings.json';
|
||||||
import signup from './signup.json';
|
import signup from './signup.json';
|
||||||
@ -53,6 +54,7 @@ export default {
|
|||||||
...macros,
|
...macros,
|
||||||
...report,
|
...report,
|
||||||
...resetPassword,
|
...resetPassword,
|
||||||
|
...search,
|
||||||
...setNewPassword,
|
...setNewPassword,
|
||||||
...settings,
|
...settings,
|
||||||
...signup,
|
...signup,
|
||||||
|
|||||||
22
app/javascript/dashboard/i18n/locale/en/search.json
Normal file
22
app/javascript/dashboard/i18n/locale/en/search.json
Normal 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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -19,8 +19,8 @@ import { frontendURL } from 'dashboard/helper/URLHelper';
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: [String, Number],
|
||||||
default: '',
|
default: 0,
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
type: String,
|
type: String,
|
||||||
@ -39,8 +39,8 @@ export default {
|
|||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
accountId: {
|
accountId: {
|
||||||
type: String,
|
type: [String, Number],
|
||||||
default: '',
|
default: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@ -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>
|
||||||
@ -17,10 +17,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{{ createdAtTime }}</span>
|
<span class="created-at">{{ createdAtTime }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h5 class="text-block-title name">
|
<h5 v-if="name" class="text-block-title name">
|
||||||
<span class="pre-text">from:</span>
|
<span class="pre-text">from:</span>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h5>
|
</h5>
|
||||||
@ -41,12 +41,8 @@ export default {
|
|||||||
mixins: [timeMixin],
|
mixins: [timeMixin],
|
||||||
props: {
|
props: {
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: Number,
|
||||||
default: '',
|
default: 0,
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
},
|
||||||
inbox: {
|
inbox: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@ -56,12 +52,8 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
thumbnail: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
accountId: {
|
accountId: {
|
||||||
type: String,
|
type: [String, Number],
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
createdAt: {
|
createdAt: {
|
||||||
@ -101,6 +93,7 @@ export default {
|
|||||||
.icon-wrap {
|
.icon-wrap {
|
||||||
width: var(--space-medium);
|
width: var(--space-medium);
|
||||||
height: var(--space-medium);
|
height: var(--space-medium);
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -122,12 +115,13 @@ export default {
|
|||||||
.conversation-details {
|
.conversation-details {
|
||||||
margin-left: var(--space-normal);
|
margin-left: var(--space-normal);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.conversation-id,
|
.conversation-id,
|
||||||
.name {
|
.name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
.created-at,
|
||||||
.pre-text {
|
.pre-text {
|
||||||
color: var(--s-600);
|
color: var(--s-600);
|
||||||
font-size: var(--font-size-mini);
|
font-size: var(--font-size-mini);
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
12
app/javascript/dashboard/modules/search/search.routes.js
Normal file
12
app/javascript/dashboard/modules/search/search.routes.js
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -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({});
|
||||||
@ -1,58 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-on-clickaway="closeSearch" class="search-wrap">
|
<div class="search-wrap">
|
||||||
<div class="search-header--wrap" :class="{ 'is-active': showSearchBox }">
|
<div class="search" :class="{ 'is-active': showSearchBox }">
|
||||||
<woot-sidemenu-icon v-if="!showSearchBox" />
|
<woot-sidemenu-icon />
|
||||||
<div class="search" :class="{ 'is-active': showSearchBox }">
|
<router-link :to="searchUrl" class="search--link" replace>
|
||||||
<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>
|
||||||
<input
|
<p class="search--label">{{ $t('CONVERSATION.SEARCH_MESSAGES') }}</p>
|
||||||
v-model="searchTerm"
|
</router-link>
|
||||||
class="search--input"
|
|
||||||
:placeholder="$t('CONVERSATION.SEARCH_MESSAGES')"
|
|
||||||
@focus="onSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<switch-layout
|
<switch-layout
|
||||||
v-if="!showSearchBox"
|
|
||||||
:is-on-expanded-layout="isOnExpandedLayout"
|
:is-on-expanded-layout="isOnExpandedLayout"
|
||||||
@toggle="$emit('toggle-conversation-layout')"
|
@toggle="$emit('toggle-conversation-layout')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -60,15 +20,13 @@
|
|||||||
import { mixin as clickaway } from 'vue-clickaway';
|
import { mixin as clickaway } from 'vue-clickaway';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import timeMixin from '../../../../mixins/time';
|
import timeMixin from '../../../../mixins/time';
|
||||||
import ResultItem from './ResultItem';
|
|
||||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
import SwitchLayout from './SwitchLayout.vue';
|
import SwitchLayout from './SwitchLayout.vue';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ResultItem,
|
|
||||||
SwitchLayout,
|
SwitchLayout,
|
||||||
},
|
},
|
||||||
|
|
||||||
directives: {
|
directives: {
|
||||||
focus: {
|
focus: {
|
||||||
inserted(el) {
|
inserted(el) {
|
||||||
@ -76,9 +34,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [timeMixin, messageFormatterMixin, clickaway],
|
mixins: [timeMixin, messageFormatterMixin, clickaway],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
isOnExpandedLayout: {
|
isOnExpandedLayout: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@ -95,59 +51,10 @@ export default {
|
|||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
conversations: 'conversationSearch/getConversations',
|
accountId: 'getCurrentAccountId',
|
||||||
uiFlags: 'conversationSearch/getUIFlags',
|
|
||||||
currentPage: 'conversationPage/getCurrentPage',
|
|
||||||
}),
|
}),
|
||||||
resultsCount() {
|
searchUrl() {
|
||||||
return this.conversations.length;
|
return frontendURL(`accounts/${this.accountId}/search`);
|
||||||
},
|
|
||||||
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 = '';
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -156,30 +63,34 @@ export default {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.search-wrap {
|
.search-wrap {
|
||||||
position: relative;
|
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 {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
padding: 0;
|
||||||
padding: 0 var(--space-smaller);
|
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid transparent;
|
||||||
|
padding: var(--space-one) var(--space-normal) var(--space-smaller)
|
||||||
|
var(--space-normal);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.search--icon {
|
.search--icon,
|
||||||
|
.search--label {
|
||||||
color: var(--w-500);
|
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 {
|
.search--input {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 0;
|
border: 0;
|
||||||
@ -215,7 +126,6 @@ input::placeholder {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
left: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-results {
|
.show-results {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import AppContainer from './Dashboard';
|
import AppContainer from './Dashboard';
|
||||||
import settings from './settings/settings.routes';
|
import settings from './settings/settings.routes';
|
||||||
import conversation from './conversation/conversation.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 contactRoutes } from './contacts/routes';
|
||||||
import { routes as notificationRoutes } from './notifications/routes';
|
import { routes as notificationRoutes } from './notifications/routes';
|
||||||
import { frontendURL } from '../../helper/URLHelper';
|
import { frontendURL } from '../../helper/URLHelper';
|
||||||
@ -18,6 +19,7 @@ export default {
|
|||||||
...conversation.routes,
|
...conversation.routes,
|
||||||
...settings.routes,
|
...settings.routes,
|
||||||
...contactRoutes,
|
...contactRoutes,
|
||||||
|
...searchRoutes,
|
||||||
...notificationRoutes,
|
...notificationRoutes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import ConversationAPI from '../../api/inbox/conversation';
|
import SearchAPI from '../../api/search';
|
||||||
import types from '../mutation-types';
|
import types from '../mutation-types';
|
||||||
export const initialState = {
|
export const initialState = {
|
||||||
records: [],
|
records: [],
|
||||||
|
fullSearchRecords: {},
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
},
|
},
|
||||||
@ -11,6 +12,9 @@ export const getters = {
|
|||||||
getConversations(state) {
|
getConversations(state) {
|
||||||
return state.records;
|
return state.records;
|
||||||
},
|
},
|
||||||
|
getFullSearchRecords(state) {
|
||||||
|
return state.fullSearchRecords;
|
||||||
|
},
|
||||||
getUIFlags(state) {
|
getUIFlags(state) {
|
||||||
return state.uiFlags;
|
return state.uiFlags;
|
||||||
},
|
},
|
||||||
@ -26,12 +30,29 @@ export const actions = {
|
|||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
data: { payload },
|
data: { payload },
|
||||||
} = await ConversationAPI.search({ q });
|
} = await SearchAPI.get({ q });
|
||||||
commit(types.SEARCH_CONVERSATIONS_SET, payload);
|
commit(types.SEARCH_CONVERSATIONS_SET, payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
} finally {
|
} 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) {
|
[types.SEARCH_CONVERSATIONS_SET](state, records) {
|
||||||
state.records = records;
|
state.records = records;
|
||||||
},
|
},
|
||||||
|
[types.FULL_SEARCH_SET](state, records) {
|
||||||
|
state.fullSearchRecords = records;
|
||||||
|
},
|
||||||
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
|
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
|
||||||
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
||||||
},
|
},
|
||||||
|
[types.FULL_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
||||||
|
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@ -261,6 +261,9 @@ export default {
|
|||||||
EDIT_MACRO: 'EDIT_MACRO',
|
EDIT_MACRO: 'EDIT_MACRO',
|
||||||
DELETE_MACRO: 'DELETE_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_UI_FLAG',
|
'SET_CONVERSATION_PARTICIPANTS_UI_FLAG',
|
||||||
SET_CONVERSATION_PARTICIPANTS: 'SET_CONVERSATION_PARTICIPANTS',
|
SET_CONVERSATION_PARTICIPANTS: 'SET_CONVERSATION_PARTICIPANTS',
|
||||||
|
|||||||
@ -6,9 +6,7 @@ json.source_id message.source_id
|
|||||||
json.inbox_id message.inbox_id
|
json.inbox_id message.inbox_id
|
||||||
json.conversation_id message.conversation.try(:display_id)
|
json.conversation_id message.conversation.try(:display_id)
|
||||||
json.created_at message.created_at.to_i
|
json.created_at message.created_at.to_i
|
||||||
json.agent do
|
json.sender message.sender.push_event_data if message.sender
|
||||||
json.partial! 'agent', formats: [:json], agent: message.conversation.try(:assignee) if message.conversation.try(:assignee).present?
|
|
||||||
end
|
|
||||||
json.inbox do
|
json.inbox do
|
||||||
json.partial! 'inbox', formats: [:json], inbox: message.inbox if message.inbox.present? && message.try(:inbox).present?
|
json.partial! 'inbox', formats: [:json], inbox: message.inbox if message.inbox.present? && message.try(:inbox).present?
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user