Compare commits
80 commits
develop
...
feat-new-s
Author | SHA1 | Date | |
---|---|---|---|
|
085efd455c | ||
|
65384cb41d | ||
|
2c90a52716 | ||
|
2a045bf19c | ||
|
63fea6bd28 | ||
|
63f97433d1 | ||
|
510b8aca30 | ||
|
6ea71008fd | ||
|
ca8d156212 | ||
|
9c0fb99dca | ||
|
ca7e912c04 | ||
|
5bb54794d8 | ||
|
32b95baeb2 | ||
|
3ec72ffce4 | ||
|
08c75a5946 | ||
|
91205627b4 | ||
|
d9d5d087d3 | ||
|
c42a611558 | ||
|
c842c98769 | ||
|
4f651d04ee | ||
|
bec5b6e6ea | ||
|
56488c6bae | ||
|
5bb4c12e6b | ||
|
3447b56b18 | ||
|
87eb798d7c | ||
|
9681827776 | ||
|
918eda22a6 | ||
|
a9b80da11c | ||
|
5c322e96a4 | ||
|
4d2afface2 | ||
|
a025271324 | ||
|
604ca395ad | ||
|
f513bdb97d | ||
|
723968f042 | ||
|
f3bea265f9 | ||
|
4eca37fd79 | ||
|
1a2349ae84 | ||
|
c03a6602f3 | ||
|
a8600d79f4 | ||
|
23cd34cbd6 | ||
|
e7c573b522 | ||
|
3406e3d0ab | ||
|
9cbb3bee6b | ||
|
35f653c460 | ||
|
f328489068 | ||
|
812c84e2b1 | ||
|
5088df4c5b | ||
|
03ba2fa0ce | ||
|
a669842fb3 | ||
|
51d48d99e4 | ||
|
ff8c0654c0 | ||
|
1ba4bed546 | ||
|
0397b5ae54 | ||
|
30365797c0 | ||
|
9c4714b658 | ||
|
a59dc72e68 | ||
|
fda2d20010 | ||
|
364b4fb254 | ||
|
dc19d6da8d | ||
|
d082049466 | ||
|
ed8929b55c | ||
|
785b303166 | ||
|
d038a93d65 | ||
|
4ac2b31f42 | ||
|
ea2f265baa | ||
|
335ea70169 | ||
|
60bb07923e | ||
|
443dbbbfd2 | ||
|
900e8501ca | ||
|
e00e049fb7 | ||
|
acb4b7ce04 | ||
|
02148c5c64 | ||
|
b456cec6a6 | ||
|
abbaeb7701 | ||
|
6e9a29ef56 | ||
|
e904a40254 | ||
|
8d09686baf | ||
|
44c0c92f83 | ||
|
4aec41bd72 | ||
|
dd75db4196 |
43 changed files with 1632 additions and 136 deletions
|
@ -2,8 +2,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||||
include Events::Types
|
include Events::Types
|
||||||
include DateRangeHelper
|
include DateRangeHelper
|
||||||
|
|
||||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
before_action :conversation, except: [:index, :meta, :search, :create, :filter, :text_search]
|
||||||
before_action :inbox, :contact, :contact_inbox, only: [:create]
|
before_action :inbox, :contact, :contact_inbox, :text_search, only: [:create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
result = conversation_finder.perform
|
result = conversation_finder.perform
|
||||||
|
@ -11,6 +11,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||||
@conversations_count = result[:count]
|
@conversations_count = result[:count]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def text_search
|
||||||
|
@result = TextSearch.new(Current.user, params).perform
|
||||||
|
end
|
||||||
|
|
||||||
def meta
|
def meta
|
||||||
result = conversation_finder.perform
|
result = conversation_finder.perform
|
||||||
@conversations_count = result[:count]
|
@conversations_count = result[:count]
|
||||||
|
|
44
app/finders/text_search.rb
Normal file
44
app/finders/text_search.rb
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
class TextSearch
|
||||||
|
attr_reader :current_user, :current_account, :params
|
||||||
|
|
||||||
|
DEFAULT_STATUS = 'open'.freeze
|
||||||
|
|
||||||
|
def initialize(current_user, params)
|
||||||
|
@current_user = current_user
|
||||||
|
@current_account = @current_user.account || Current.account
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
set_inboxes
|
||||||
|
{
|
||||||
|
messages: filter_messages,
|
||||||
|
conversations: filter_conversations,
|
||||||
|
contacts: filter_contacts
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_inboxes
|
||||||
|
@inbox_ids = @current_user.assigned_inboxes.pluck(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def filter_conversations
|
||||||
|
@conversations = PgSearch.multisearch((@params[:q]).to_s).where(
|
||||||
|
inbox_id: @inbox_ids, account_id: @current_account, searchable_type: 'Conversation'
|
||||||
|
).joins('INNER JOIN conversations ON pg_search_documents.searchable_id = conversations.id').includes(:searchable).limit(20).collect(&:searchable)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_messages
|
||||||
|
@messages = PgSearch.multisearch((@params[:q]).to_s).where(
|
||||||
|
inbox_id: @inbox_ids, account_id: @current_account, searchable_type: 'Message'
|
||||||
|
).joins('INNER JOIN messages ON pg_search_documents.searchable_id = messages.id').includes(:searchable).limit(20).collect(&:searchable)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_contacts
|
||||||
|
@contacts = PgSearch.multisearch((@params[:q]).to_s).where(
|
||||||
|
account_id: @current_account, searchable_type: 'Contact'
|
||||||
|
).joins('INNER JOIN contacts ON pg_search_documents.searchable_id = contacts.id').includes(:searchable).limit(20).collect(&:searchable)
|
||||||
|
end
|
||||||
|
end
|
|
@ -45,6 +45,14 @@ class ConversationApi extends ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fullSearch({ q }) {
|
||||||
|
return axios.get(`${this.url}/text_search`, {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
toggleStatus({ conversationId, status, snoozedUntil = null }) {
|
toggleStatus({ conversationId, status, snoozedUntil = null }) {
|
||||||
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
|
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
|
||||||
status,
|
status,
|
||||||
|
|
|
@ -20,6 +20,7 @@ describe('#ConversationAPI', () => {
|
||||||
expect(conversationAPI).toHaveProperty('meta');
|
expect(conversationAPI).toHaveProperty('meta');
|
||||||
expect(conversationAPI).toHaveProperty('sendEmailTranscript');
|
expect(conversationAPI).toHaveProperty('sendEmailTranscript');
|
||||||
expect(conversationAPI).toHaveProperty('filter');
|
expect(conversationAPI).toHaveProperty('filter');
|
||||||
|
expect(conversationAPI).toHaveProperty('fullSearch');
|
||||||
});
|
});
|
||||||
|
|
||||||
describeWithAPIMock('API calls', context => {
|
describeWithAPIMock('API calls', context => {
|
||||||
|
@ -64,6 +65,16 @@ describe('#ConversationAPI', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#fullSearch', () => {
|
||||||
|
conversationAPI.fullSearch({ q: 'john' });
|
||||||
|
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/conversations/text_search',
|
||||||
|
{
|
||||||
|
params: { q: 'john' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('#toggleStatus', () => {
|
it('#toggleStatus', () => {
|
||||||
conversationAPI.toggleStatus({ conversationId: 12, status: 'online' });
|
conversationAPI.toggleStatus({ conversationId: 12, status: 'online' });
|
||||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||||
|
|
|
@ -27,6 +27,7 @@ import settings from './settings.json';
|
||||||
import signup from './signup.json';
|
import signup from './signup.json';
|
||||||
import teamsSettings from './teamsSettings.json';
|
import teamsSettings from './teamsSettings.json';
|
||||||
import whatsappTemplates from './whatsappTemplates.json';
|
import whatsappTemplates from './whatsappTemplates.json';
|
||||||
|
import search from './search.json';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...advancedFilters,
|
...advancedFilters,
|
||||||
|
@ -58,4 +59,5 @@ export default {
|
||||||
...signup,
|
...signup,
|
||||||
...teamsSettings,
|
...teamsSettings,
|
||||||
...whatsappTemplates,
|
...whatsappTemplates,
|
||||||
|
...search,
|
||||||
};
|
};
|
||||||
|
|
20
app/javascript/dashboard/i18n/locale/en/search.json
Normal file
20
app/javascript/dashboard/i18n/locale/en/search.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"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}'",
|
||||||
|
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||||
|
"INPUT_PLACEHOLDER": "Search message content, contact name, email or phone or conversations",
|
||||||
|
"BOT_LABEL": "Bot",
|
||||||
|
"READ_MORE": "Read more"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,56 +1,17 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-on-clickaway="closeSearch" class="search-wrap">
|
<div class="search-wrap">
|
||||||
<div class="search-header--wrap">
|
<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">
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<fluent-icon icon="search" class="search--icon" size="28" />
|
<fluent-icon icon="search" class="search--icon" size="28" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<p class="search--label">{{ $t('CONVERSATION.SEARCH_MESSAGES') }}</p>
|
||||||
v-model="searchTerm"
|
</router-link>
|
||||||
class="search--input"
|
<switch-layout
|
||||||
:placeholder="$t('CONVERSATION.SEARCH_MESSAGES')"
|
:is-on-expanded-layout="isOnExpandedLayout"
|
||||||
@focus="onSearch"
|
@toggle="$emit('toggle-conversation-layout')"
|
||||||
/>
|
/>
|
||||||
<switch-layout
|
|
||||||
:is-on-expanded-layout="isOnExpandedLayout"
|
|
||||||
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -59,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) {
|
||||||
|
@ -75,9 +34,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
mixins: [timeMixin, messageFormatterMixin, clickaway],
|
mixins: [timeMixin, messageFormatterMixin, clickaway],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
isOnExpandedLayout: {
|
isOnExpandedLayout: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
@ -94,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 = '';
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -155,29 +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;
|
|
||||||
min-height: var(--space-large);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
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;
|
||||||
|
@ -214,7 +127,6 @@ input::placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
left: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-results {
|
.show-results {
|
||||||
|
|
|
@ -2,6 +2,7 @@ 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 contactRoutes } from './contacts/routes';
|
import { routes as contactRoutes } from './contacts/routes';
|
||||||
|
import { routes as searchRoutes } from './search/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';
|
||||||
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
||||||
|
@ -18,6 +19,7 @@ export default {
|
||||||
...conversation.routes,
|
...conversation.routes,
|
||||||
...settings.routes,
|
...settings.routes,
|
||||||
...contactRoutes,
|
...contactRoutes,
|
||||||
|
...searchRoutes,
|
||||||
...notificationRoutes,
|
...notificationRoutes,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
<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: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,102 @@
|
||||||
|
<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();
|
||||||
|
this.handler = e => {
|
||||||
|
if (e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.$refs.searchInput.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', this.handler);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('keydown', this.handler);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
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));
|
||||||
|
padding-right: var(--space-mega);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
.icon {
|
||||||
|
color: var(--w-500) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-container {
|
||||||
|
padding-left: var(--space-slab);
|
||||||
|
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-400);
|
||||||
|
border-radius: var(--border-radius-normal);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,81 @@
|
||||||
|
<template>
|
||||||
|
<router-link :to="navigateTo" class="contact-item">
|
||||||
|
<thumbnail :src="contact.thumbnail" size="42px" :username="contact.name" />
|
||||||
|
<div class="contact-details">
|
||||||
|
<p class="name">{{ contact.name }}</p>
|
||||||
|
<div class="details-meta">
|
||||||
|
<p v-if="contact.email" class="email">{{ contact.email }}</p>
|
||||||
|
<p v-if="contact.phone_number" class="separator">·</p>
|
||||||
|
<p v-if="contact.phone_number" class="phone_number">
|
||||||
|
{{ contact.phone_number }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Thumbnail,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
contact: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
accountId: 'getCurrentAccountId',
|
||||||
|
}),
|
||||||
|
navigateTo() {
|
||||||
|
return frontendURL(
|
||||||
|
`accounts/${this.accountId}/contacts/${this.contact.id}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.contact-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-small);
|
||||||
|
|
||||||
|
.contact-details {
|
||||||
|
margin-left: var(--space-slab);
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--s-700);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
font-size: var(--font-size-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-meta {
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--s-500);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
}
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.details-meta > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-right: calc(1rem * 0);
|
||||||
|
margin-left: calc(1rem * calc(1 - 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--s-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,34 @@
|
||||||
|
<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 :contact="contact" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</search-result-section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
|
import SearchResultContactItem from './SearchResultContactItem.vue';
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
SearchResultSection,
|
||||||
|
SearchResultContactItem,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
contacts: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -0,0 +1,120 @@
|
||||||
|
<template>
|
||||||
|
<router-link :to="navigateTo" class="list-item">
|
||||||
|
<thumbnail size="42px" :username="conversation.contact.name" />
|
||||||
|
<div class="conversation-details">
|
||||||
|
<div class="conversation-meta">
|
||||||
|
<div class="left-column">
|
||||||
|
<inbox-name :inbox="conversation.inbox" />
|
||||||
|
<div class="agent-details">
|
||||||
|
<thumbnail size="16px" :username="conversation.agent.name" />
|
||||||
|
<span>{{ conversation.agent.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="conversation-id">
|
||||||
|
Conversation Id: {{ conversation.id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="timestamp">
|
||||||
|
<time-ago :timestamp="conversation.created_at" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="name">{{ conversation.contact.name }}</p>
|
||||||
|
<div v-dompurify-html="messageContent" class="message-content" />
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
|
import InboxName from 'dashboard/components/widgets/InboxName.vue';
|
||||||
|
import timeAgo from 'dashboard/components/ui/TimeAgo';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Thumbnail,
|
||||||
|
InboxName,
|
||||||
|
timeAgo,
|
||||||
|
},
|
||||||
|
mixins: [messageFormatterMixin],
|
||||||
|
props: {
|
||||||
|
conversation: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
messageContent() {
|
||||||
|
return this.formatMessage(this.conversation.message.content) || '';
|
||||||
|
},
|
||||||
|
...mapGetters({
|
||||||
|
accountId: 'getCurrentAccountId',
|
||||||
|
}),
|
||||||
|
navigateTo() {
|
||||||
|
return frontendURL(
|
||||||
|
`accounts/${this.accountId}/conversations/${this.conversation.id}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: start;
|
||||||
|
padding: var(--space-small);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--s-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-details {
|
||||||
|
margin-left: var(--space-slab);
|
||||||
|
flex-grow: 1;
|
||||||
|
.name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--s-700);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
font-size: var(--font-size-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--s-500);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
}
|
||||||
|
.details-meta > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-right: calc(1rem * 0);
|
||||||
|
margin-left: calc(1rem * calc(1 - 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.left-column {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
.agent-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: var(--space-slab);
|
||||||
|
span {
|
||||||
|
margin-left: var(--space-small);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.conversation-id {
|
||||||
|
margin-left: var(--space-slab);
|
||||||
|
color: var(--s-500);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,34 @@
|
||||||
|
<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 :conversation="conversation" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</search-result-section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
|
import SearchResultConversationItem from './SearchResultConversationItem.vue';
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
SearchResultSection,
|
||||||
|
SearchResultConversationItem,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
conversations: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -0,0 +1,145 @@
|
||||||
|
<template>
|
||||||
|
<router-link :to="navigateTo" class="list-item">
|
||||||
|
<thumbnail :src="getThumbnail" size="42px" :username="getName" />
|
||||||
|
<div class="message-details">
|
||||||
|
<div class="conversation-meta">
|
||||||
|
<p class="inbox-name">
|
||||||
|
<inbox-name :inbox="message.inbox" />
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<span class="timestamp">
|
||||||
|
<time-ago :timestamp="message.created_at" />
|
||||||
|
</span>
|
||||||
|
<p class="conversation-id">Conv: {{ message.conversation_id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="name">{{ getName }}</p>
|
||||||
|
<read-more :shrink="isOverflowing" @expand="isOverflowing = false">
|
||||||
|
<div
|
||||||
|
ref="messageContainer"
|
||||||
|
v-dompurify-html="messageContent"
|
||||||
|
class="message-content"
|
||||||
|
/>
|
||||||
|
</read-more>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||||
|
import InboxName from 'dashboard/components/widgets/InboxName.vue';
|
||||||
|
import timeAgo from 'dashboard/components/ui/TimeAgo';
|
||||||
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||||
|
import ReadMore from './ReadMore';
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Thumbnail,
|
||||||
|
ReadMore,
|
||||||
|
InboxName,
|
||||||
|
timeAgo,
|
||||||
|
},
|
||||||
|
mixins: [messageFormatterMixin],
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isOverflowing: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
messageContent() {
|
||||||
|
return this.formatMessage(this.message.content);
|
||||||
|
},
|
||||||
|
...mapGetters({
|
||||||
|
accountId: 'getCurrentAccountId',
|
||||||
|
}),
|
||||||
|
navigateTo() {
|
||||||
|
return frontendURL(
|
||||||
|
`accounts/${this.accountId}/conversations/${this.message.conversation_id}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getThumbnail() {
|
||||||
|
return this.message.sender && this.message.sender.thumbnail
|
||||||
|
? this.message.sender.thumbnail
|
||||||
|
: this.$t('SEARCH.BOT_LABEL');
|
||||||
|
},
|
||||||
|
getName() {
|
||||||
|
return this.message && this.message.sender && this.message.sender.name
|
||||||
|
? this.message.sender.name
|
||||||
|
: this.$t('SEARCH.BOT_LABEL');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.isOverflowing = this.$refs.messageContainer.offsetHeight > 150;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: start;
|
||||||
|
padding: var(--space-small);
|
||||||
|
|
||||||
|
.message-details {
|
||||||
|
margin-left: var(--space-slab);
|
||||||
|
flex: 1;
|
||||||
|
.name {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--s-700);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
font-size: var(--font-size-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--s-500);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
}
|
||||||
|
.details-meta > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-right: calc(1rem * 0);
|
||||||
|
margin-left: calc(1rem * calc(1 - 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--s-50);
|
||||||
|
::v-deep {
|
||||||
|
&::after {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(255, 255, 255, 0),
|
||||||
|
var(--s-50) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
.timestamp {
|
||||||
|
color: var(--s-500);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
}
|
||||||
|
.conversation-id {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-name {
|
||||||
|
margin: var(--space-small) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,34 @@
|
||||||
|
<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-message-item :message="message" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</search-result-section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SearchResultSection from './SearchResultSection.vue';
|
||||||
|
import SearchResultMessageItem from './SearchResultMessageItem.vue';
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
SearchResultSection,
|
||||||
|
SearchResultMessageItem,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
messages: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -0,0 +1,79 @@
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="label-container">
|
||||||
|
<p>{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
<div v-if="empty" class="empty">
|
||||||
|
<fluent-icon icon="info" size="24px" 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);
|
||||||
|
background-color: var(--s-25);
|
||||||
|
z-index: 50;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-top-width: 1px;
|
||||||
|
border-color: var(--s-100);
|
||||||
|
border-style: solid;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
p {
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
color: var(--s-500);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: var(--space-normal);
|
||||||
|
.icon {
|
||||||
|
font-size: var(--space-two);
|
||||||
|
color: var(--s-500);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.empty-state__text {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: var(--space-small);
|
||||||
|
color: var(--s-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,34 @@
|
||||||
|
<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>
|
|
@ -0,0 +1,154 @@
|
||||||
|
<template>
|
||||||
|
<section class="search-root">
|
||||||
|
<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>
|
||||||
|
</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 { mapGetters } from 'vuex';
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
search(q) {
|
||||||
|
this.query = q;
|
||||||
|
this.$store.dispatch('conversationSearch/fullSearch', { q });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
header {
|
||||||
|
padding: var(--space-large) var(--space-normal) 0 var(--space-normal);
|
||||||
|
}
|
||||||
|
.search-root {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
.search-results {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
12
app/javascript/dashboard/routes/dashboard/search/routes.js
Normal file
12
app/javascript/dashboard/routes/dashboard/search/routes.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
/* eslint arrow-body-style: 0 */
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
|
@ -2,6 +2,7 @@ import ConversationAPI from '../../api/inbox/conversation';
|
||||||
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;
|
||||||
},
|
},
|
||||||
|
@ -34,15 +38,36 @@ export const actions = {
|
||||||
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 ConversationAPI.fullSearch({ q });
|
||||||
|
commit(types.FULL_SEARCH_SET, data.payload);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error
|
||||||
|
} finally {
|
||||||
|
commit(types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mutations = {
|
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 {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { actions } from '../../conversationSearch';
|
import { actions } from '../../conversationSearch';
|
||||||
import types from '../../../mutation-types';
|
import types from '../../../mutation-types';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { fullSearchResponse } from './fixtures';
|
||||||
const commit = jest.fn();
|
const commit = jest.fn();
|
||||||
global.axios = axios;
|
global.axios = axios;
|
||||||
jest.mock('axios');
|
jest.mock('axios');
|
||||||
|
@ -41,4 +42,36 @@ describe('#actions', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('#fullSearch', () => {
|
||||||
|
it('sends correct actions if no query param is provided', () => {
|
||||||
|
actions.fullSearch({ commit }, { q: '' });
|
||||||
|
expect(commit.mock.calls).toEqual([[types.FULL_SEARCH_SET, []]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct actions if query param is provided and API call is success', async () => {
|
||||||
|
axios.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
payload: fullSearchResponse,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await actions.fullSearch({ commit }, { q: 'value' });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.FULL_SEARCH_SET, []],
|
||||||
|
[types.FULL_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.FULL_SEARCH_SET, fullSearchResponse],
|
||||||
|
[types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends correct actions if query param is provided and API call is errored', async () => {
|
||||||
|
axios.get.mockRejectedValue({});
|
||||||
|
await actions.fullSearch({ commit }, { q: 'value' });
|
||||||
|
expect(commit.mock.calls).toEqual([
|
||||||
|
[types.FULL_SEARCH_SET, []],
|
||||||
|
[types.FULL_SEARCH_SET_UI_FLAG, { isFetching: true }],
|
||||||
|
[types.FULL_SEARCH_SET_UI_FLAG, { isFetching: false }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,179 @@
|
||||||
|
export const fullSearchResponse = {
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
id: 93,
|
||||||
|
created_at: 1668756114,
|
||||||
|
contact: {
|
||||||
|
id: 168,
|
||||||
|
name: 'summer-wildflower-139',
|
||||||
|
},
|
||||||
|
inbox: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Acme Support',
|
||||||
|
channel_type: 'Channel::WebWidget',
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
content: 'Hello',
|
||||||
|
id: 1324,
|
||||||
|
sender_name: 'summer-wildflower-139',
|
||||||
|
message_type: 0,
|
||||||
|
created_at: 1668756114,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Hi There 👋',
|
||||||
|
id: 1325,
|
||||||
|
sender_name: 'Welcome Bot',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1668756116,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '',
|
||||||
|
id: 1326,
|
||||||
|
sender_name: 'Welcome Bot',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1668756118,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Enjoy the minion',
|
||||||
|
id: 1327,
|
||||||
|
sender_name: 'Welcome Bot',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1668756118,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Do you like Pizza',
|
||||||
|
id: 1328,
|
||||||
|
sender_name: 'Welcome Bot',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1668756118,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '',
|
||||||
|
id: 1329,
|
||||||
|
sender_name: 'Welcome Bot',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1668756129,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: "Here's some markdown for you",
|
||||||
|
id: 1330,
|
||||||
|
sender_name: 'Welcome Bot',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1668756129,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
'[link text](https://google.com) **Some Bold text** # A large title',
|
||||||
|
id: 1331,
|
||||||
|
sender_name: 'Welcome Bot',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1668756129,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Hi',
|
||||||
|
id: 1332,
|
||||||
|
sender_name: 'summer-wildflower-139',
|
||||||
|
message_type: 0,
|
||||||
|
created_at: 1668756133,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Hi',
|
||||||
|
id: 1333,
|
||||||
|
sender_name: 'Welcome Bot',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1668756134,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Bye',
|
||||||
|
id: 1334,
|
||||||
|
sender_name: 'summer-wildflower-139',
|
||||||
|
message_type: 0,
|
||||||
|
created_at: 1668756141,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
account_id: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 96,
|
||||||
|
created_at: 1669368899,
|
||||||
|
contact: {
|
||||||
|
id: 6,
|
||||||
|
name: 'Fayaz',
|
||||||
|
},
|
||||||
|
inbox: {
|
||||||
|
id: 15,
|
||||||
|
name: 'Whatsapp',
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
'Your package has been shipped. It will be delivered in 12 business days.',
|
||||||
|
id: 1351,
|
||||||
|
sender_name: 'Fayaz Ahmed',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1669368899,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'This is your flight confirmation for 123-123 on 123.',
|
||||||
|
id: 1352,
|
||||||
|
sender_name: 'Fayaz Ahmed',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1669368988,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Esta é a sua confirmação de voo para das-zxc em qwe.',
|
||||||
|
id: 1353,
|
||||||
|
sender_name: 'Fayaz Ahmed',
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1669369021,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
account_id: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contacts: [
|
||||||
|
{
|
||||||
|
additional_attributes: {},
|
||||||
|
availability_status: 'offline',
|
||||||
|
email: null,
|
||||||
|
id: 94,
|
||||||
|
name: 'purple-hill-409',
|
||||||
|
phone_number: null,
|
||||||
|
identifier: null,
|
||||||
|
thumbnail: '',
|
||||||
|
custom_attributes: {},
|
||||||
|
last_activity_at: 1649918760,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 1008,
|
||||||
|
content: 'Where can I download my invoices?',
|
||||||
|
inbox_id: 1,
|
||||||
|
conversation_id: 79,
|
||||||
|
message_type: 0,
|
||||||
|
content_type: 'text',
|
||||||
|
status: 'sent',
|
||||||
|
content_attributes: {},
|
||||||
|
created_at: 1663941027,
|
||||||
|
private: false,
|
||||||
|
source_id: null,
|
||||||
|
sender: {
|
||||||
|
additional_attributes: {},
|
||||||
|
custom_attributes: {
|
||||||
|
vfer: false,
|
||||||
|
xcvb: 'hello',
|
||||||
|
},
|
||||||
|
email: null,
|
||||||
|
id: 133,
|
||||||
|
identifier: null,
|
||||||
|
name: 'morning-violet-166',
|
||||||
|
phone_number: null,
|
||||||
|
thumbnail: '',
|
||||||
|
type: 'contact',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -262,4 +262,8 @@ export default {
|
||||||
|
|
||||||
SET_CONVERSATION_WATCHERS_UI_FLAG: 'SET_CONVERSATION_WATCHERS_UI_FLAG',
|
SET_CONVERSATION_WATCHERS_UI_FLAG: 'SET_CONVERSATION_WATCHERS_UI_FLAG',
|
||||||
SET_CONVERSATION_WATCHERS: 'SET_CONVERSATION_WATCHERS',
|
SET_CONVERSATION_WATCHERS: 'SET_CONVERSATION_WATCHERS',
|
||||||
|
|
||||||
|
// Full Search
|
||||||
|
FULL_SEARCH_SET: 'FULL_SEARCH_SET',
|
||||||
|
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
|
||||||
};
|
};
|
||||||
|
|
|
@ -174,7 +174,7 @@ export default {
|
||||||
feedback_message: this.feedbackMessage,
|
feedback_message: this.feedbackMessage,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error?.response?.data?.error;
|
const errorMessage = error?.response?.data?.message;
|
||||||
this.errorMessage = errorMessage || this.$t('SURVEY.API.ERROR_MESSAGE');
|
this.errorMessage = errorMessage || this.$t('SURVEY.API.ERROR_MESSAGE');
|
||||||
} finally {
|
} finally {
|
||||||
this.isUpdating = false;
|
this.isUpdating = false;
|
||||||
|
|
9
app/jobs/conversations/account_based_search_job.rb
Normal file
9
app/jobs/conversations/account_based_search_job.rb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
class Conversations::AccountBasedSearchJob < ApplicationJob
|
||||||
|
queue_as :async_database_migration
|
||||||
|
|
||||||
|
def perform(account_id)
|
||||||
|
Contact.rebuild_pg_search_documents(account_id)
|
||||||
|
Conversation.rebuild_pg_search_documents(account_id)
|
||||||
|
Message.rebuild_pg_search_documents(account_id)
|
||||||
|
end
|
||||||
|
end
|
9
app/jobs/conversations/multi_search_job.rb
Normal file
9
app/jobs/conversations/multi_search_job.rb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
class Conversations::MultiSearchJob < ApplicationJob
|
||||||
|
queue_as :async_database_migration
|
||||||
|
|
||||||
|
def perform
|
||||||
|
Account.all.each do |account|
|
||||||
|
Conversations::AccountBasedSearchJob.perform_later(account.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
74
app/models/concerns/multi_searchable_helpers.rb
Normal file
74
app/models/concerns/multi_searchable_helpers.rb
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
module MultiSearchableHelpers
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
PgSearch.multisearch_options = {
|
||||||
|
using: {
|
||||||
|
trigram: {
|
||||||
|
word_similarity: true,
|
||||||
|
threshold: 0.8
|
||||||
|
},
|
||||||
|
tsearch: { any_word: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_contact_search_document
|
||||||
|
return if contact_pg_search_record.present?
|
||||||
|
|
||||||
|
initialize_contact_pg_search_record.update!(
|
||||||
|
content: "#{contact.id} #{contact.email} #{contact.name} #{contact.phone_number} #{contact.account_id}",
|
||||||
|
conversation_id: id,
|
||||||
|
inbox_id: inbox_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# NOTE: To add multi search records with conversation_id associated to contacts for previously added records.
|
||||||
|
# We can not find conversation_id from contacts directly so we added this joins here.
|
||||||
|
def self.rebuild_pg_search_documents(account_id)
|
||||||
|
return unless self.name == 'Conversation'
|
||||||
|
rebuild_search_documents(account_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.rebuild_search_documents(account_id)
|
||||||
|
connection.execute <<~SQL.squish
|
||||||
|
INSERT INTO pg_search_documents (searchable_type, searchable_id, content, account_id, conversation_id, inbox_id, created_at, updated_at)
|
||||||
|
SELECT 'Conversation' AS searchable_type,
|
||||||
|
conversations.id AS searchable_id,
|
||||||
|
CONCAT_WS(' ', conversations.display_id, contacts.email, contacts.name, contacts.phone_number, conversations.account_id) AS content,
|
||||||
|
conversations.account_id::int AS account_id,
|
||||||
|
conversations.id::int AS conversation_id,
|
||||||
|
conversations.inbox_id::int AS inbox_id,
|
||||||
|
now() AS created_at,
|
||||||
|
now() AS updated_at
|
||||||
|
FROM conversations
|
||||||
|
INNER JOIN contacts
|
||||||
|
ON conversations.contact_id = contacts.id
|
||||||
|
WHERE conversations.account_id = #{account_id}
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_pg_search_record
|
||||||
|
contacts_pg_search_records.find_by(conversation_id: id, inbox_id: inbox_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_contact_pg_search_record
|
||||||
|
record = contacts_pg_search_records.find_by(conversation_id: nil, inbox_id: nil)
|
||||||
|
|
||||||
|
return record if record.present?
|
||||||
|
|
||||||
|
PgSearch::Document.new(
|
||||||
|
searchable_type: 'Contact',
|
||||||
|
searchable_id: contact_id,
|
||||||
|
account_id: account_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def contacts_pg_search_records
|
||||||
|
PgSearch::Document.where(
|
||||||
|
searchable_type: 'Contact',
|
||||||
|
searchable_id: contact_id,
|
||||||
|
account_id: account_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -26,6 +26,13 @@ class Contact < ApplicationRecord
|
||||||
include Avatarable
|
include Avatarable
|
||||||
include AvailabilityStatusable
|
include AvailabilityStatusable
|
||||||
include Labelable
|
include Labelable
|
||||||
|
include PgSearch::Model
|
||||||
|
include MultiSearchableHelpers
|
||||||
|
|
||||||
|
multisearchable(
|
||||||
|
against: [:id, :email, :name, :phone_number],
|
||||||
|
additional_attributes: ->(contact) { { conversation_id: nil, account_id: contact.account_id, inbox_id: nil } }
|
||||||
|
)
|
||||||
|
|
||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },
|
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },
|
||||||
|
@ -140,6 +147,28 @@ class Contact < ApplicationRecord
|
||||||
email_format
|
email_format
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# NOTE: To add multi search records with conversation_id associated to contacts for previously added records.
|
||||||
|
# We can not find conversation_id from contacts directly so we added this joins here.
|
||||||
|
def self.rebuild_pg_search_documents(account_id)
|
||||||
|
return super unless name == 'Contact'
|
||||||
|
|
||||||
|
connection.execute <<~SQL.squish
|
||||||
|
INSERT INTO pg_search_documents (searchable_type, searchable_id, content, account_id, conversation_id, inbox_id, created_at, updated_at)
|
||||||
|
SELECT 'Contact' AS searchable_type,
|
||||||
|
contacts.id AS searchable_id,
|
||||||
|
CONCAT_WS(' ', contacts.id, contacts.email, contacts.name, contacts.phone_number, contacts.account_id) AS content,
|
||||||
|
contacts.account_id::int AS account_id,
|
||||||
|
conversations.id::int AS conversation_id,
|
||||||
|
conversations.inbox_id::int AS inbox_id,
|
||||||
|
now() AS created_at,
|
||||||
|
now() AS updated_at
|
||||||
|
FROM contacts
|
||||||
|
INNER JOIN conversations
|
||||||
|
ON conversations.contact_id = contacts.id
|
||||||
|
WHERE contacts.account_id = #{account_id}
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ip_lookup
|
def ip_lookup
|
||||||
|
|
|
@ -48,7 +48,15 @@ class Conversation < ApplicationRecord
|
||||||
include ActivityMessageHandler
|
include ActivityMessageHandler
|
||||||
include UrlHelper
|
include UrlHelper
|
||||||
include SortHandler
|
include SortHandler
|
||||||
|
include PgSearch::Model
|
||||||
|
include MultiSearchableHelpers
|
||||||
|
|
||||||
|
multisearchable(
|
||||||
|
against: [:display_id, :name, :email, :phone_number, :account_id],
|
||||||
|
additional_attributes: lambda { |conversation|
|
||||||
|
{ conversation_id: conversation.id, account_id: conversation.account_id, inbox_id: conversation.inbox_id }
|
||||||
|
}
|
||||||
|
)
|
||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
validates :inbox_id, presence: true
|
validates :inbox_id, presence: true
|
||||||
before_validation :validate_additional_attributes
|
before_validation :validate_additional_attributes
|
||||||
|
@ -93,9 +101,11 @@ class Conversation < ApplicationRecord
|
||||||
|
|
||||||
after_update_commit :execute_after_update_commit_callbacks
|
after_update_commit :execute_after_update_commit_callbacks
|
||||||
after_create_commit :notify_conversation_creation
|
after_create_commit :notify_conversation_creation
|
||||||
|
after_create_commit :update_contact_search_document, if: :contact_id?
|
||||||
after_commit :set_display_id, unless: :display_id?
|
after_commit :set_display_id, unless: :display_id?
|
||||||
|
|
||||||
delegate :auto_resolve_duration, to: :account
|
delegate :auto_resolve_duration, to: :account
|
||||||
|
delegate :name, :email, :phone_number, to: :contact, allow_nil: true
|
||||||
|
|
||||||
def can_reply?
|
def can_reply?
|
||||||
channel = inbox&.channel
|
channel = inbox&.channel
|
||||||
|
|
|
@ -33,6 +33,14 @@
|
||||||
class Message < ApplicationRecord
|
class Message < ApplicationRecord
|
||||||
include MessageFilterHelpers
|
include MessageFilterHelpers
|
||||||
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
|
||||||
|
include PgSearch::Model
|
||||||
|
include MultiSearchableHelpers
|
||||||
|
|
||||||
|
multisearchable(
|
||||||
|
against: [:content],
|
||||||
|
if: :allowed_message_types?,
|
||||||
|
additional_attributes: ->(message) { { conversation_id: message.conversation_id, account_id: message.account_id, inbox_id: message.inbox_id } }
|
||||||
|
)
|
||||||
|
|
||||||
before_validation :ensure_content_type
|
before_validation :ensure_content_type
|
||||||
|
|
||||||
|
@ -162,6 +170,26 @@ class Message < ApplicationRecord
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# NOTE: To add multi search records with conversation_id associated to contacts for previously added records.
|
||||||
|
# We can not find conversation_id from contacts directly so we added this joins here.
|
||||||
|
def self.rebuild_pg_search_documents(account_id)
|
||||||
|
return super unless name == 'Message'
|
||||||
|
|
||||||
|
connection.execute <<~SQL.squish
|
||||||
|
INSERT INTO pg_search_documents (searchable_type, searchable_id, content, account_id, conversation_id, inbox_id, created_at, updated_at)
|
||||||
|
SELECT 'Message' AS searchable_type,
|
||||||
|
messages.id AS searchable_id,
|
||||||
|
CONCAT_WS(' ', messages.content) AS content,
|
||||||
|
messages.account_id::int AS account_id,
|
||||||
|
messages.conversation_id::int AS conversation_id,
|
||||||
|
messages.inbox_id::int AS inbox_id,
|
||||||
|
now() AS created_at,
|
||||||
|
now() AS updated_at
|
||||||
|
FROM messages
|
||||||
|
WHERE messages.account_id = #{account_id}
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def ensure_content_type
|
def ensure_content_type
|
||||||
|
@ -274,4 +302,8 @@ class Message < ApplicationRecord
|
||||||
conversation.update_columns(last_activity_at: created_at)
|
conversation.update_columns(last_activity_at: created_at)
|
||||||
# rubocop:enable Rails/SkipsModelValidations
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allowed_message_types?
|
||||||
|
incoming? || outgoing?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
json.payload do
|
||||||
|
json.conversations do
|
||||||
|
json.array! @result[:conversations] do |conversation|
|
||||||
|
json.id conversation.display_id
|
||||||
|
json.account_id conversation.account_id
|
||||||
|
json.created_at conversation.created_at.to_i
|
||||||
|
json.message do
|
||||||
|
json.partial! 'api/v1/models/multi_search_message', formats: [:json], message: conversation.messages.try(:first)
|
||||||
|
end
|
||||||
|
json.contact do
|
||||||
|
json.partial! 'api/v1/models/multi_search_contact', formats: [:json], contact: conversation.contact if conversation.try(:contact).present?
|
||||||
|
end
|
||||||
|
json.inbox do
|
||||||
|
json.partial! 'api/v1/models/multi_search_inbox', formats: [:json], inbox: conversation.inbox if conversation.try(:inbox).present?
|
||||||
|
end
|
||||||
|
json.agent do
|
||||||
|
json.partial! 'api/v1/models/multi_search_agent', formats: [:json], agent: conversation.assignee if conversation.try(:assignee).present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.contacts do
|
||||||
|
json.array! @result[:contacts] do |contact|
|
||||||
|
json.partial! 'api/v1/models/multi_search_contact', formats: [:json], contact: contact
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.messages do
|
||||||
|
json.array! @result[:messages] do |message|
|
||||||
|
json.partial! 'api/v1/models/multi_search_message', formats: [:json], message: message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,7 +2,8 @@ json.id message.id
|
||||||
json.content message.content
|
json.content message.content
|
||||||
json.inbox_id message.inbox_id
|
json.inbox_id message.inbox_id
|
||||||
json.echo_id message.echo_id if message.echo_id
|
json.echo_id message.echo_id if message.echo_id
|
||||||
json.conversation_id message.conversation.display_id
|
# For deleted conversation, messages are not yet deleted [because of destroy_async] for this we added try block
|
||||||
|
json.conversation_id message.conversation.try(:display_id)
|
||||||
json.message_type message.message_type_before_type_cast
|
json.message_type message.message_type_before_type_cast
|
||||||
json.content_type message.content_type
|
json.content_type message.content_type
|
||||||
json.status message.status
|
json.status message.status
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
json.id agent.id
|
||||||
|
json.available_name agent.available_name
|
||||||
|
json.email agent.email
|
||||||
|
json.name agent.name
|
||||||
|
json.role agent.role
|
|
@ -0,0 +1,5 @@
|
||||||
|
json.email contact.email
|
||||||
|
json.id contact.id
|
||||||
|
json.name contact.name
|
||||||
|
json.phone_number contact.phone_number
|
||||||
|
json.identifier contact.identifier
|
|
@ -0,0 +1,4 @@
|
||||||
|
json.id inbox.id
|
||||||
|
json.channel_id inbox.channel_id
|
||||||
|
json.name inbox.name
|
||||||
|
json.channel_type inbox.channel_type
|
16
app/views/api/v1/models/_multi_search_message.json.jbuilder
Normal file
16
app/views/api/v1/models/_multi_search_message.json.jbuilder
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
json.id message.id
|
||||||
|
json.content message.content
|
||||||
|
json.message_type message.message_type_before_type_cast
|
||||||
|
json.content_type message.content_type
|
||||||
|
json.source_id message.source_id
|
||||||
|
json.inbox_id message.inbox_id
|
||||||
|
json.conversation_id message.try(:conversation_id)
|
||||||
|
json.created_at message.created_at.to_i
|
||||||
|
json.agent do
|
||||||
|
if message.conversation.try(:assignee).present?
|
||||||
|
json.partial! 'api/v1/models/multi_search_agent', formats: [:json], agent: message.conversation.try(:assignee)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
json.inbox do
|
||||||
|
json.partial! 'api/v1/models/multi_search_inbox', formats: [:json], inbox: message.inbox if message.inbox.present? && message.try(:inbox).present?
|
||||||
|
end
|
|
@ -71,6 +71,7 @@ Rails.application.routes.draw do
|
||||||
get :meta
|
get :meta
|
||||||
get :search
|
get :search
|
||||||
post :filter
|
post :filter
|
||||||
|
get :text_search
|
||||||
end
|
end
|
||||||
scope module: :conversations do
|
scope module: :conversations do
|
||||||
resources :messages, only: [:index, :create, :destroy]
|
resources :messages, only: [:index, :create, :destroy]
|
||||||
|
|
|
@ -12,20 +12,21 @@
|
||||||
# even put in dynamic logic, like a host-specific queue.
|
# even put in dynamic logic, like a host-specific queue.
|
||||||
# http://www.mikeperham.com/2013/11/13/advanced-sidekiq-host-specific-queues/
|
# http://www.mikeperham.com/2013/11/13/advanced-sidekiq-host-specific-queues/
|
||||||
:queues:
|
:queues:
|
||||||
- [low, 1]
|
- [async_database_migration, 1]
|
||||||
- [scheduled_jobs, 1]
|
- [low, 2]
|
||||||
- [webhooks, 1]
|
- [scheduled_jobs, 2]
|
||||||
- [bots, 1]
|
- [webhooks, 2]
|
||||||
- [active_storage_analysis, 1]
|
- [bots, 2]
|
||||||
- [action_mailbox_incineration, 1]
|
- [active_storage_analysis, 2]
|
||||||
- [active_storage_purge, 1]
|
- [action_mailbox_incineration, 2]
|
||||||
- [integrations, 2]
|
- [active_storage_purge, 2]
|
||||||
- [default, 2]
|
- [integrations, 3]
|
||||||
- [mailers, 2]
|
- [default, 3]
|
||||||
- [medium, 3]
|
- [mailers, 3]
|
||||||
- [events, 3]
|
- [medium, 4]
|
||||||
- [action_mailbox_routing, 3]
|
- [events, 4]
|
||||||
- [high, 5]
|
- [action_mailbox_routing, 4]
|
||||||
|
- [high, 6]
|
||||||
- [critical, 10]
|
- [critical, 10]
|
||||||
|
|
||||||
# you can override concurrency based on environment
|
# you can override concurrency based on environment
|
||||||
|
|
22
db/migrate/20221205081737_create_pg_search_documents.rb
Normal file
22
db/migrate/20221205081737_create_pg_search_documents.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
class CreatePgSearchDocuments < ActiveRecord::Migration[6.1]
|
||||||
|
def up
|
||||||
|
say_with_time('Creating table for pg_search multisearch') do
|
||||||
|
create_table :pg_search_documents do |t|
|
||||||
|
t.text :content
|
||||||
|
t.bigint 'conversation_id'
|
||||||
|
t.bigint 'account_id'
|
||||||
|
t.bigint 'inbox_id'
|
||||||
|
t.belongs_to :searchable, polymorphic: true, index: true
|
||||||
|
t.timestamps null: false
|
||||||
|
end
|
||||||
|
add_index :pg_search_documents, :account_id
|
||||||
|
add_index :pg_search_documents, :conversation_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
say_with_time('Dropping table for pg_search multisearch') do
|
||||||
|
drop_table :pg_search_documents
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
10
db/migrate/20221212061802_enable_multi_searchable.rb
Normal file
10
db/migrate/20221212061802_enable_multi_searchable.rb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
class EnableMultiSearchable < ActiveRecord::Migration[6.1]
|
||||||
|
def up
|
||||||
|
::Conversations::MultiSearchJob.perform_now
|
||||||
|
execute 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
PgSearch::Document.delete_all
|
||||||
|
end
|
||||||
|
end
|
20
db/schema.rb
20
db/schema.rb
|
@ -14,6 +14,7 @@ ActiveRecord::Schema.define(version: 2022_12_19_162759) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
|
enable_extension "pg_trgm"
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
|
||||||
|
@ -399,7 +400,7 @@ ActiveRecord::Schema.define(version: 2022_12_19_162759) do
|
||||||
t.datetime "agent_last_seen_at"
|
t.datetime "agent_last_seen_at"
|
||||||
t.jsonb "additional_attributes", default: {}
|
t.jsonb "additional_attributes", default: {}
|
||||||
t.bigint "contact_inbox_id"
|
t.bigint "contact_inbox_id"
|
||||||
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
|
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
|
||||||
t.string "identifier"
|
t.string "identifier"
|
||||||
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
|
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
|
||||||
t.bigint "team_id"
|
t.bigint "team_id"
|
||||||
|
@ -674,6 +675,20 @@ ActiveRecord::Schema.define(version: 2022_12_19_162759) do
|
||||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "pg_search_documents", force: :cascade do |t|
|
||||||
|
t.text "content"
|
||||||
|
t.bigint "conversation_id"
|
||||||
|
t.bigint "account_id"
|
||||||
|
t.bigint "inbox_id"
|
||||||
|
t.string "searchable_type"
|
||||||
|
t.bigint "searchable_id"
|
||||||
|
t.datetime "created_at", precision: 6, null: false
|
||||||
|
t.datetime "updated_at", precision: 6, null: false
|
||||||
|
t.index ["account_id"], name: "index_pg_search_documents_on_account_id"
|
||||||
|
t.index ["conversation_id"], name: "index_pg_search_documents_on_conversation_id"
|
||||||
|
t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "platform_app_permissibles", force: :cascade do |t|
|
create_table "platform_app_permissibles", force: :cascade do |t|
|
||||||
t.bigint "platform_app_id", null: false
|
t.bigint "platform_app_id", null: false
|
||||||
t.string "permissible_type", null: false
|
t.string "permissible_type", null: false
|
||||||
|
@ -836,6 +851,9 @@ ActiveRecord::Schema.define(version: 2022_12_19_162759) do
|
||||||
t.jsonb "custom_attributes", default: {}
|
t.jsonb "custom_attributes", default: {}
|
||||||
t.string "type"
|
t.string "type"
|
||||||
t.text "message_signature"
|
t.text "message_signature"
|
||||||
|
t.datetime "locked_at"
|
||||||
|
t.integer "failed_attempts"
|
||||||
|
t.string "unlock_token"
|
||||||
t.index ["email"], name: "index_users_on_email"
|
t.index ["email"], name: "index_users_on_email"
|
||||||
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
|
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
|
|
87
spec/finders/text_search_spec.rb
Normal file
87
spec/finders/text_search_spec.rb
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe ::TextSearch do
|
||||||
|
subject(:text_search) { described_class.new(user_1, params) }
|
||||||
|
|
||||||
|
let!(:account) { create(:account) }
|
||||||
|
let!(:user_1) { create(:user, account: account) }
|
||||||
|
let!(:user_2) { create(:user, account: account) }
|
||||||
|
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:inbox_member, user: user_1, inbox: inbox)
|
||||||
|
create(:inbox_member, user: user_2, inbox: inbox)
|
||||||
|
|
||||||
|
create(:contact, name: '1223', account_id: account.id)
|
||||||
|
create(:contact, name: 'Potter', account_id: account.id)
|
||||||
|
contact_2 = create(:contact, name: 'Harry Potter', account_id: account.id, email: 'harry@chatwoot.com')
|
||||||
|
conversation_1 = create(:conversation, account: account, inbox: inbox, assignee: user_1, display_id: 1213)
|
||||||
|
conversation_2 = create(:conversation, account: account, inbox: inbox, assignee: user_1, display_id: 1223)
|
||||||
|
create(:conversation, account: account, inbox: inbox, assignee: user_1, status: 'resolved', display_id: 13, contact_id: contact_2.id)
|
||||||
|
create(:conversation, account: account, inbox: inbox, assignee: user_2, display_id: 14)
|
||||||
|
create(:conversation, account: account, inbox: inbox, display_id: 15)
|
||||||
|
Current.account = account
|
||||||
|
|
||||||
|
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'Ask Lisa')
|
||||||
|
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'message_12')
|
||||||
|
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'message_13')
|
||||||
|
|
||||||
|
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'Pottery Barn order')
|
||||||
|
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'message_22')
|
||||||
|
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'message_23')
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'with text search' do
|
||||||
|
it 'filter conversations by number' do
|
||||||
|
params = { q: '122' }
|
||||||
|
result = described_class.new(user_1, params).perform
|
||||||
|
expect(result[:conversations].length).to eq 1
|
||||||
|
expect(result[:contacts].length).to eq 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filter message and contacts by string' do
|
||||||
|
params = { q: 'pot' }
|
||||||
|
result = described_class.new(user_1, params).perform
|
||||||
|
expect(result[:messages].length).to be 1
|
||||||
|
expect(result[:contacts].length).to be 2
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filter conversations by contact details' do
|
||||||
|
params = { q: 'pot' }
|
||||||
|
result = described_class.new(user_1, params).perform
|
||||||
|
expect(result[:conversations].length).to be 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filter conversations by contact email' do
|
||||||
|
params = { q: 'harry@chatwoot.com' }
|
||||||
|
result = described_class.new(user_1, params).perform
|
||||||
|
expect(result[:conversations].length).to be 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when create records in tables including multi search' do
|
||||||
|
let(:contact) { create(:contact, name: 'Welma', account_id: account.id, email: 'welma@scoobydoo.com') }
|
||||||
|
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user_1, status: 'open', contact_id: contact.id) }
|
||||||
|
|
||||||
|
it 'conversation creation pg search records' do
|
||||||
|
contact_search_record = PgSearch::Document.find_by(searchable_id: contact.id, searchable_type: contact.class.name)
|
||||||
|
conversation_search_record = PgSearch::Document.find_by(searchable_id: conversation.id, searchable_type: conversation.class.name)
|
||||||
|
|
||||||
|
expect(contact_search_record).to be_present
|
||||||
|
expect(conversation_search_record).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'conversation deletion deletes pg search records' do
|
||||||
|
contact.destroy!
|
||||||
|
conversation.destroy!
|
||||||
|
|
||||||
|
contact_search_record = PgSearch::Document.find_by(searchable_id: contact.id, searchable_type: contact.class.name)
|
||||||
|
conversation_search_record = PgSearch::Document.find_by(searchable_id: conversation.id, searchable_type: conversation.class.name)
|
||||||
|
|
||||||
|
expect(contact_search_record).to be_nil
|
||||||
|
expect(conversation_search_record).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue