Add new search page
This commit is contained in:
parent
335ea70169
commit
ea2f265baa
24 changed files with 1054 additions and 105 deletions
|
@ -45,6 +45,14 @@ class ConversationApi extends ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
fullSearch({ q }) {
|
||||
return axios.get(`${this.url}/text_search`, {
|
||||
params: {
|
||||
q,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleStatus({ conversationId, status, snoozedUntil = null }) {
|
||||
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
|
||||
status,
|
||||
|
|
|
@ -20,6 +20,7 @@ describe('#ConversationAPI', () => {
|
|||
expect(conversationAPI).toHaveProperty('meta');
|
||||
expect(conversationAPI).toHaveProperty('sendEmailTranscript');
|
||||
expect(conversationAPI).toHaveProperty('filter');
|
||||
expect(conversationAPI).toHaveProperty('fullSearch');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
conversationAPI.toggleStatus({ conversationId: 12, status: 'online' });
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
|
|
|
@ -27,6 +27,7 @@ import settings from './settings.json';
|
|||
import signup from './signup.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import search from './search.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
|
@ -58,4 +59,5 @@ export default {
|
|||
...signup,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...search,
|
||||
};
|
||||
|
|
17
app/javascript/dashboard/i18n/locale/en/search.json
Normal file
17
app/javascript/dashboard/i18n/locale/en/search.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "All",
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages"
|
||||
},
|
||||
"SECTION": {
|
||||
"CONTACTS": "CONTACTS",
|
||||
"CONVERSATIONS": "CONVERSATIONS",
|
||||
"MESSAGES": "MESSAGES"
|
||||
},
|
||||
"EMPTY_STATE": "No results found for this query",
|
||||
"PLACEHOLDER_KEYBINDING": "/ to focus"
|
||||
}
|
||||
}
|
|
@ -1,55 +1,18 @@
|
|||
<template>
|
||||
<div v-on-clickaway="closeSearch" class="search-wrap">
|
||||
<div class="search-wrap">
|
||||
<div class="search" :class="{ 'is-active': showSearchBox }">
|
||||
<woot-sidemenu-icon />
|
||||
<div class="icon">
|
||||
<fluent-icon icon="search" class="search--icon" size="28" />
|
||||
</div>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
class="search--input"
|
||||
:placeholder="$t('CONVERSATION.SEARCH_MESSAGES')"
|
||||
@focus="onSearch"
|
||||
/>
|
||||
<router-link :to="searchUrl" class="search--link">
|
||||
<div class="icon">
|
||||
<fluent-icon icon="search" class="search--icon" size="28" />
|
||||
</div>
|
||||
<p class="search--label">{{ $t('CONVERSATION.SEARCH_MESSAGES') }}</p>
|
||||
</router-link>
|
||||
<switch-layout
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@toggle="$emit('toggle-conversation-layout')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showSearchBox" class="results-wrap">
|
||||
<div class="show-results">
|
||||
<div>
|
||||
<div class="result-view">
|
||||
<div class="result">
|
||||
{{ $t('CONVERSATION.SEARCH.RESULT_TITLE') }}
|
||||
<span v-if="resultsCount" class="message-counter">
|
||||
({{ resultsCount }})
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="uiFlags.isFetching" class="search--activity-message">
|
||||
<woot-spinner size="" />
|
||||
{{ $t('CONVERSATION.SEARCH.LOADING_MESSAGE') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSearchResult" class="search-results--container">
|
||||
<result-item
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.messageId"
|
||||
:conversation-id="conversation.id"
|
||||
:user-name="conversation.contact.name"
|
||||
:timestamp="conversation.created_at"
|
||||
:messages="conversation.messages"
|
||||
:search-term="searchTerm"
|
||||
:inbox-name="conversation.inbox.name"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="showEmptyResult" class="search--activity-no-message">
|
||||
{{ $t('CONVERSATION.SEARCH.NO_MATCHING_RESULTS') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -57,15 +20,13 @@
|
|||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { mapGetters } from 'vuex';
|
||||
import timeMixin from '../../../../mixins/time';
|
||||
import ResultItem from './ResultItem';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import SwitchLayout from './SwitchLayout.vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
export default {
|
||||
components: {
|
||||
ResultItem,
|
||||
SwitchLayout,
|
||||
},
|
||||
|
||||
directives: {
|
||||
focus: {
|
||||
inserted(el) {
|
||||
|
@ -73,9 +34,7 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [timeMixin, messageFormatterMixin, clickaway],
|
||||
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
|
@ -92,59 +51,10 @@ export default {
|
|||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
conversations: 'conversationSearch/getConversations',
|
||||
uiFlags: 'conversationSearch/getUIFlags',
|
||||
currentPage: 'conversationPage/getCurrentPage',
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
resultsCount() {
|
||||
return this.conversations.length;
|
||||
},
|
||||
showSearchResult() {
|
||||
return (
|
||||
this.searchTerm && this.conversations.length && !this.uiFlags.isFetching
|
||||
);
|
||||
},
|
||||
showEmptyResult() {
|
||||
return (
|
||||
this.searchTerm &&
|
||||
!this.conversations.length &&
|
||||
!this.uiFlags.isFetching
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
searchTerm(newValue) {
|
||||
if (this.typingTimer) {
|
||||
clearTimeout(this.typingTimer);
|
||||
}
|
||||
|
||||
this.typingTimer = setTimeout(() => {
|
||||
this.hasSearched = true;
|
||||
this.$store.dispatch('conversationSearch/get', { q: newValue });
|
||||
}, 1000);
|
||||
},
|
||||
currentPage() {
|
||||
this.clearSearchTerm();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$store.dispatch('conversationSearch/get', { q: '' });
|
||||
bus.$on('clearSearchInput', () => {
|
||||
this.clearSearchTerm();
|
||||
});
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSearch() {
|
||||
this.showSearchBox = true;
|
||||
},
|
||||
closeSearch() {
|
||||
this.showSearchBox = false;
|
||||
},
|
||||
clearSearchTerm() {
|
||||
this.searchTerm = '';
|
||||
searchUrl() {
|
||||
return frontendURL(`accounts/${this.accountId}/search`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -163,12 +73,24 @@ export default {
|
|||
var(--space-normal);
|
||||
|
||||
&:hover {
|
||||
.search--icon {
|
||||
.search--icon,
|
||||
.search--label {
|
||||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search--link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search--label {
|
||||
color: var(--color-body);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search--input {
|
||||
align-items: center;
|
||||
border: 0;
|
||||
|
|
|
@ -2,6 +2,7 @@ import AppContainer from './Dashboard';
|
|||
import settings from './settings/settings.routes';
|
||||
import conversation from './conversation/conversation.routes';
|
||||
import { routes as contactRoutes } from './contacts/routes';
|
||||
import { routes as searchRoutes } from './search/routes';
|
||||
import { routes as notificationRoutes } from './notifications/routes';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
||||
|
@ -18,6 +19,7 @@ export default {
|
|||
...conversation.routes,
|
||||
...settings.routes,
|
||||
...contactRoutes,
|
||||
...searchRoutes,
|
||||
...notificationRoutes,
|
||||
],
|
||||
},
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
mounted() {
|
||||
this.handler = event => {
|
||||
this.$emit('keyup', event);
|
||||
};
|
||||
window.addEventListener('keyup', this.handler);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('keyup', this.handler);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
div {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<div>
|
||||
<search-focus @keyup="focusSearch" />
|
||||
<div class="input-container">
|
||||
<div class="icon-container">
|
||||
<fluent-icon icon="search" class="icon" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
ref="searchInput"
|
||||
type="search"
|
||||
placeholder="Search message content, contact name, email or phone or conversations"
|
||||
:value="searchQuery"
|
||||
@input="debounceSearch"
|
||||
/>
|
||||
<div class="key-binding">
|
||||
<span>{{ $t('SEARCH.PLACEHOLDER_KEYBINDING') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SearchFocus from './SearchFocus.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SearchFocus,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.searchInput.focus();
|
||||
},
|
||||
methods: {
|
||||
debounceSearch(e) {
|
||||
this.searchQuery = e.target.value;
|
||||
clearTimeout(this.debounce);
|
||||
this.debounce = setTimeout(async () => {
|
||||
this.$emit('search', this.searchQuery);
|
||||
}, 500);
|
||||
},
|
||||
focusSearch(e) {
|
||||
if (e.key === '/') this.$refs.searchInput.focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
</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: 1.4rem;
|
||||
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,29 @@
|
|||
<template>
|
||||
<search-result-section
|
||||
:title="$t('SEARCH.SECTION.CONTACTS')"
|
||||
:empty="!contacts.length"
|
||||
>
|
||||
<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: () => [],
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,103 @@
|
|||
<template>
|
||||
<router-link :to="navigateTo" class="list-item">
|
||||
<thumbnail size="42px" :username="conversation.contact.name" />
|
||||
<div class="conversation-details">
|
||||
<div class="conversation-meta">
|
||||
<inbox-name :inbox="conversation.inbox" />
|
||||
<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.messages[this.conversation.messages.length - 1]
|
||||
.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;
|
||||
margin-bottom: var(--space-small);
|
||||
.timestamp {
|
||||
color: var(--s-500);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<search-result-section
|
||||
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
|
||||
:empty="!conversations.length"
|
||||
>
|
||||
<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: () => [],
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<router-link :to="navigateTo" class="list-item">
|
||||
<thumbnail :src="getThumbnail" size="42px" :username="getName" />
|
||||
<div class="message-details">
|
||||
<p class="name">{{ getName }}</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 { mapGetters } from 'vuex';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
mixins: [messageFormatterMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
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
|
||||
: '';
|
||||
},
|
||||
getName() {
|
||||
return this.message && this.message.sender && this.message.sender.name
|
||||
? this.message.sender.name
|
||||
: '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</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);
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<search-result-section
|
||||
:title="$t('SEARCH.SECTION.MESSAGES')"
|
||||
:empty="!messages.length"
|
||||
>
|
||||
<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: () => [],
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<section>
|
||||
<div class="label-container">
|
||||
<p>{{ title }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
<div v-if="empty" class="empty">
|
||||
<fluent-icon icon="info" size="32px" class="icon" />
|
||||
<p class="empty-state__text">
|
||||
{{ $t('SEARCH.EMPTY_STATE') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
empty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</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: #dbdbdb;
|
||||
border-style: solid;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
p {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--b-600);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.empty {
|
||||
padding: var(--space-normal);
|
||||
.icon {
|
||||
font-size: 2rem;
|
||||
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,148 @@
|
|||
<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"
|
||||
/>
|
||||
<search-result-messages-list
|
||||
v-if="filterMessages"
|
||||
:messages="messages"
|
||||
/>
|
||||
<search-result-conversations-list
|
||||
v-if="filterConversations"
|
||||
:conversations="conversations"
|
||||
/>
|
||||
</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';
|
||||
},
|
||||
tabs() {
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
name: this.$t('SEARCH.TABS.ALL'),
|
||||
count:
|
||||
this.contacts.length +
|
||||
this.conversations.length +
|
||||
this.messages.length,
|
||||
},
|
||||
{
|
||||
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.$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';
|
||||
export const initialState = {
|
||||
records: [],
|
||||
fullSearchRecords: {},
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
},
|
||||
|
@ -11,6 +12,9 @@ export const getters = {
|
|||
getConversations(state) {
|
||||
return state.records;
|
||||
},
|
||||
getFullSearchRecords(state) {
|
||||
return state.fullSearchRecords;
|
||||
},
|
||||
getUIFlags(state) {
|
||||
return state.uiFlags;
|
||||
},
|
||||
|
@ -34,15 +38,36 @@ export const actions = {
|
|||
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 = {
|
||||
[types.SEARCH_CONVERSATIONS_SET](state, records) {
|
||||
state.records = records;
|
||||
},
|
||||
[types.FULL_SEARCH_SET](state, records) {
|
||||
state.fullSearchRecords = records;
|
||||
},
|
||||
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
|
||||
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
||||
},
|
||||
[types.FULL_SEARCH_SET_UI_FLAG](state, uiFlags) {
|
||||
state.uiFlags = { ...state.uiFlags, ...uiFlags };
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { actions } from '../../conversationSearch';
|
||||
import types from '../../../mutation-types';
|
||||
import axios from 'axios';
|
||||
import { fullSearchResponse } from './fixtures';
|
||||
const commit = jest.fn();
|
||||
global.axios = 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: 'SET_CONVERSATION_WATCHERS',
|
||||
|
||||
// Full Search
|
||||
FULL_SEARCH_SET: 'FULL_SEARCH_SET',
|
||||
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
|
||||
};
|
||||
|
|
|
@ -4,9 +4,11 @@ module MultiSearchableHelpers
|
|||
included do
|
||||
PgSearch.multisearch_options = {
|
||||
using: {
|
||||
trigram: {},
|
||||
# trigram: {},
|
||||
tsearch: {
|
||||
any_word: true
|
||||
prefix: true,
|
||||
any_word: true,
|
||||
normalization: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ class EnableMultiSearchable < ActiveRecord::Migration[6.1]
|
|||
Contact.rebuild_pg_search_documents
|
||||
PgSearch::Multisearch.rebuild(Conversation)
|
||||
PgSearch::Multisearch.rebuild(Message)
|
||||
execute 'create extension pg_trgm;'
|
||||
# execute 'create extension pg_trgm;'
|
||||
end
|
||||
|
||||
def down
|
||||
|
|
Loading…
Reference in a new issue