Compare commits

..

80 commits

Author SHA1 Message Date
fayazara
085efd455c Updated ui for conversation cards 2022-12-26 18:04:47 +05:30
fayazara
65384cb41d Add conversation and inbox details to conversations and messages 2022-12-26 17:53:24 +05:30
Fayaz Ahmed
2c90a52716
Merge branch 'feat/5913-search-improvements' into feat-new-search-ui 2022-12-26 14:22:57 +05:30
Tejaswini Chile
2a045bf19c Merge branch 'feat/5913-search-improvements' of https://github.com/chatwoot/chatwoot into feat/5913-search-improvements 2022-12-26 13:45:22 +05:30
Tejaswini Chile
63fea6bd28 Merge branch 'feat/5913-search-improvements' of https://github.com/chatwoot/chatwoot into feat/5913-search-improvements 2022-12-26 13:44:43 +05:30
Fayaz Ahmed
63f97433d1
Merge branch 'feat/5913-search-improvements' into feat-new-search-ui 2022-12-26 13:21:10 +05:30
Tejaswini Chile
510b8aca30 Merge branch 'feat/5913-search-improvements' of https://github.com/chatwoot/chatwoot into feat/5913-search-improvements 2022-12-26 13:06:14 +05:30
Tejaswini Chile
6ea71008fd fix: added conversation created_at and first message in the text searched conversation data 2022-12-26 13:02:26 +05:30
Tejaswini Chile
ca8d156212 fix: added conversation created_at and first message in the text searched conversation data 2022-12-26 12:55:50 +05:30
Tejaswini Chile
9c0fb99dca spec fixes 2022-12-23 18:18:01 +05:30
Tejaswini Chile
ca7e912c04 codeclimate fix 2022-12-23 17:16:02 +05:30
Tejaswini Chile
5bb54794d8 fix: spec 2022-12-23 17:03:45 +05:30
Tejaswini Chile
32b95baeb2 fix: spec 2022-12-23 15:58:19 +05:30
Tejaswini Chile
3ec72ffce4
Merge branch 'develop' into feat/5913-search-improvements 2022-12-23 15:54:06 +05:30
Tejaswini Chile
08c75a5946 fix: agent role based search 2022-12-23 15:52:17 +05:30
Tejaswini Chile
91205627b4 fix: agent role based search 2022-12-23 15:43:01 +05:30
Tejaswini Chile
d9d5d087d3 fix: Account based rebuild index 2022-12-23 14:04:09 +05:30
Fayaz Ahmed
c42a611558
Merge branch 'feat/5913-search-improvements' into feat-new-search-ui 2022-12-22 18:37:22 +05:30
Tejaswini Chile
c842c98769 fix: update in the text search query with includes 2022-12-21 22:53:25 +05:30
Tejaswini Chile
4f651d04ee fix: update in the text search query with includes 2022-12-21 19:07:05 +05:30
Tejaswini Chile
bec5b6e6ea fix: reducing JSON contents from search data 2022-12-21 13:30:25 +05:30
Tejaswini Chile
56488c6bae fix: specs and PR feedbacks 2022-12-21 13:30:25 +05:30
Tejaswini Chile
5bb4c12e6b fix: update inbox details for text search json 2022-12-21 13:30:25 +05:30
Tejaswini Chile
3447b56b18 fix: update inbox details for text search json 2022-12-21 13:30:25 +05:30
Tejaswini Chile
87eb798d7c fix: added the threshold for the multisearch 2022-12-21 13:30:25 +05:30
Tejaswini Chile
9681827776 fix: message with inbox and agent details 2022-12-21 13:30:25 +05:30
Tejaswini Chile
918eda22a6 fix: added json for agents details in conversation 2022-12-21 13:30:25 +05:30
Tejaswini Chile
a9b80da11c fix: Conversation to include contacts data 2022-12-21 13:30:25 +05:30
Tejaswini Chile
5c322e96a4 fix: Added pg_trgm extension 2022-12-21 13:30:25 +05:30
Tejaswini Chile
4d2afface2 fix: unwanted changes and the comments 2022-12-21 13:30:25 +05:30
Tejaswini Chile
a025271324 message json fix 2022-12-21 13:30:25 +05:30
Tejaswini Chile
604ca395ad fix: issue with current account not being present for some user 2022-12-21 13:30:25 +05:30
Tejaswini Chile
f513bdb97d fix: migration for rebuilding multi model search 2022-12-21 13:30:25 +05:30
Tejaswini Chile
723968f042 fix: specs 2022-12-21 13:30:25 +05:30
Tejaswini Chile
f3bea265f9 fix: JSON format 2022-12-21 13:30:25 +05:30
Tejaswini Chile
4eca37fd79 fix: JSON format 2022-12-21 13:30:25 +05:30
Tejaswini Chile
1a2349ae84 fix: new endpoint for the text search 2022-12-21 13:30:25 +05:30
Tejaswini Chile
c03a6602f3 fix: new endpoint for the text search 2022-12-21 13:30:25 +05:30
Tejaswini Chile
a8600d79f4 fix: search improvements for multiple model with separate results 2022-12-21 13:30:25 +05:30
Tejaswini Chile
23cd34cbd6 feat: Search improvements 2022-12-21 13:30:22 +05:30
fayazara
e7c573b522 Remove extra bottom margin 2022-12-19 16:23:06 +05:30
Sivin Varghese
3406e3d0ab
Merge branch 'feat/5913-search-improvements' into feat-new-search-ui 2022-12-19 14:01:12 +05:30
Tejaswini Chile
9cbb3bee6b
Merge branch 'develop' into feat/5913-search-improvements 2022-12-19 14:00:09 +05:30
fayazara
35f653c460 Revert z index change 2022-12-19 13:54:54 +05:30
fayazara
f328489068 review changes 2022-12-19 13:52:45 +05:30
Fayaz Ahmed
812c84e2b1
Merge branch 'feat/5913-search-improvements' into feat-new-search-ui 2022-12-19 13:52:16 +05:30
Tejaswini Chile
5088df4c5b fix: update inbox details for text search json 2022-12-19 13:49:54 +05:30
Tejaswini Chile
03ba2fa0ce fix: update inbox details for text search json 2022-12-19 13:35:53 +05:30
fayazara
a669842fb3 Show conversation meta on message item 2022-12-19 13:33:42 +05:30
Sivin Varghese
51d48d99e4
Merge branch 'feat/5913-search-improvements' into feat-new-search-ui 2022-12-19 10:52:09 +05:30
Tejaswini Chile
ff8c0654c0
Merge branch 'develop' into feat/5913-search-improvements 2022-12-18 21:07:27 +05:30
Tejaswini Chile
1ba4bed546
Merge branch 'develop' into feat/5913-search-improvements 2022-12-16 18:53:16 +05:30
Tejaswini Chile
0397b5ae54 fix: added the threshold for the multisearch 2022-12-16 18:43:24 +05:30
Tejaswini Chile
30365797c0 fix: message with inbox and agent details 2022-12-16 13:18:07 +05:30
Tejaswini Chile
9c4714b658 fix: added json for agents details in conversation 2022-12-16 13:10:44 +05:30
Tejaswini Chile
a59dc72e68 fix: Conversation to include contacts data 2022-12-16 12:07:10 +05:30
fayazara
fda2d20010 Review changes 2022-12-15 20:30:18 +05:30
fayazara
364b4fb254 Skip the character limit check if numbers are entered 2022-12-15 19:46:24 +05:30
fayazara
dc19d6da8d Revert commented backend code 2022-12-15 19:41:07 +05:30
fayazara
d082049466 Show messages fullwidth 2022-12-15 19:27:10 +05:30
fayazara
ed8929b55c Fix focusing on firefox 2022-12-15 19:13:41 +05:30
fayazara
785b303166 Limit search characters before making the search request 2022-12-15 19:06:56 +05:30
fayazara
d038a93d65 Truncate long messages 2022-12-15 18:56:26 +05:30
fayazara
4ac2b31f42 Review changes 2022-12-14 15:13:30 +05:30
fayazara
ea2f265baa Add new search page 2022-12-13 23:15:40 +05:30
Fayaz Ahmed
335ea70169
Merge branch 'develop' into feat/5913-search-improvements 2022-12-13 14:22:51 +05:30
Tejaswini Chile
60bb07923e fix: Added pg_trgm extension 2022-12-13 12:24:51 +05:30
Tejaswini Chile
443dbbbfd2 fix: unwanted changes and the comments 2022-12-13 09:39:05 +05:30
Tejaswini Chile
900e8501ca message json fix 2022-12-12 21:01:09 +05:30
Tejaswini Chile
e00e049fb7
Merge branch 'develop' into feat/5913-search-improvements 2022-12-12 20:13:00 +05:30
Tejaswini Chile
acb4b7ce04
Merge branch 'develop' into feat/5913-search-improvements 2022-12-12 14:55:47 +05:30
Tejaswini Chile
02148c5c64 fix: issue with current account not being present for some user 2022-12-12 13:16:27 +05:30
Tejaswini Chile
b456cec6a6 fix: migration for rebuilding multi model search 2022-12-12 11:57:01 +05:30
Tejaswini Chile
abbaeb7701 fix: specs 2022-12-12 11:12:29 +05:30
Tejaswini Chile
6e9a29ef56 fix: JSON format 2022-12-12 00:37:12 +05:30
Tejaswini Chile
e904a40254 fix: JSON format 2022-12-11 20:18:55 +05:30
Tejaswini Chile
8d09686baf fix: new endpoint for the text search 2022-12-11 20:05:27 +05:30
Tejaswini Chile
44c0c92f83 fix: new endpoint for the text search 2022-12-10 01:43:49 +05:30
Tejaswini Chile
4aec41bd72 fix: search improvements for multiple model with separate results 2022-12-10 01:42:07 +05:30
Tejaswini Chile
dd75db4196 feat: Search improvements 2022-12-10 01:42:07 +05:30
43 changed files with 1632 additions and 136 deletions

View file

@ -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]

View 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

View file

@ -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,

View file

@ -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(

View file

@ -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,
}; };

View 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"
}
}

View file

@ -1,73 +1,32 @@
<template> <template>
<div v-on-clickaway="closeSearch" class="search-wrap"> <div class="search-wrap">
<div class="search-header--wrap">
<woot-sidemenu-icon v-if="!showSearchBox" />
<div class="search" :class="{ 'is-active': showSearchBox }"> <div class="search" :class="{ 'is-active': showSearchBox }">
<woot-sidemenu-icon />
<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"
:placeholder="$t('CONVERSATION.SEARCH_MESSAGES')"
@focus="onSearch"
/>
<switch-layout <switch-layout
:is-on-expanded-layout="isOnExpandedLayout" :is-on-expanded-layout="isOnExpandedLayout"
@toggle="$emit('toggle-conversation-layout')" @toggle="$emit('toggle-conversation-layout')"
/> />
</div> </div>
</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>
</template> </template>
<script> <script>
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 {

View file

@ -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,
], ],
}, },

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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,
},
];

View file

@ -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 {

View file

@ -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 }],
]);
});
});
}); });

View file

@ -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',
},
},
],
};

View file

@ -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',
}; };

View file

@ -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;

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
json.id inbox.id
json.channel_id inbox.channel_id
json.name inbox.name
json.channel_type inbox.channel_type

View 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

View file

@ -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]

View file

@ -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

View 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

View 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

View file

@ -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

View 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