chore: Search improvements (#10801)

- Adds pagination support for search.
- Use composition API on all search related component.
- Minor UI improvements.
- Adds missing specs

Loom video
https://www.loom.com/share/5b01afa5c9204e7d97ff81b215621dde?sid=82ca6d22-ca8c-4d5e-8740-ba06ca4051ba
This commit is contained in:
Sivin Varghese
2025-02-03 19:34:50 +05:30
committed by GitHub
parent 3fb77fe806
commit bd94e5062d
20 changed files with 898 additions and 646 deletions

View File

@@ -14,26 +14,29 @@ class SearchAPI extends ApiClient {
}); });
} }
contacts({ q }) { contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, { return axios.get(`${this.url}/contacts`, {
params: { params: {
q, q,
page: page,
}, },
}); });
} }
conversations({ q }) { conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, { return axios.get(`${this.url}/conversations`, {
params: { params: {
q, q,
page: page,
}, },
}); });
} }
messages({ q }) { messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, { return axios.get(`${this.url}/messages`, {
params: { params: {
q, q,
page: page,
}, },
}); });
} }

View File

@@ -11,7 +11,10 @@
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"MESSAGES": "Messages" "MESSAGES": "Messages"
}, },
"VIEW_MORE": "View more",
"LOAD_MORE": "Load more",
"SEARCHING_DATA": "Searching", "SEARCHING_DATA": "Searching",
"LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",

View File

@@ -1,73 +1,59 @@
<script> <script setup>
import { ref, useTemplateRef, onMounted, watch, nextTick } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ReadMore from './ReadMore.vue'; import ReadMore from './ReadMore.vue';
export default { const props = defineProps({
components: { author: {
ReadMore, type: String,
default: '',
}, },
props: { content: {
author: { type: String,
type: String, default: '',
default: '',
},
content: {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
}, },
setup() { searchTerm: {
const { formatMessage, highlightContent } = useMessageFormatter(); type: String,
return { default: '',
formatMessage,
highlightContent,
};
}, },
data() { });
return {
isOverflowing: false,
};
},
computed: {
messageContent() {
return this.formatMessage(this.content);
},
},
mounted() {
this.$watch(() => {
return this.$refs.messageContainer;
}, this.setOverflow);
this.$nextTick(this.setOverflow); const { highlightContent } = useMessageFormatter();
},
methods: { const messageContainer = useTemplateRef('messageContainer');
setOverflow() { const isOverflowing = ref(false);
const wrap = this.$refs.messageContainer;
if (wrap) { const setOverflow = () => {
const message = wrap.querySelector('.message-content'); const wrap = messageContainer.value;
this.isOverflowing = message.offsetHeight > 150; if (wrap) {
} const message = wrap.querySelector('.message-content');
}, isOverflowing.value = message.offsetHeight > 150;
escapeHtml(html) { }
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
},
prepareContent(content = '') {
const escapedText = this.escapeHtml(content);
return this.highlightContent(
escapedText,
this.searchTerm,
'searchkey--highlight'
);
},
},
}; };
const escapeHtml = html => {
var text = document.createTextNode(html);
var p = document.createElement('p');
p.appendChild(text);
return p.innerText;
};
const prepareContent = (content = '') => {
const escapedText = escapeHtml(content);
return highlightContent(
escapedText,
props.searchTerm,
'searchkey--highlight'
);
};
onMounted(() => {
watch(() => {
return messageContainer.value;
}, setOverflow);
nextTick(setOverflow);
});
</script> </script>
<template> <template>

View File

@@ -1,4 +1,6 @@
<script setup> <script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
shrink: { shrink: {
type: Boolean, type: Boolean,
@@ -18,17 +20,18 @@ defineEmits(['expand']);
> >
<slot /> <slot />
<div <div
class="bg-n-slate-3 rounded-md dark:bg-n-solid-3 absolute left-0 right-0 z-20 mx-auto mt-0 max-w-max bottom-2 backdrop-blur[100px]" v-if="shrink"
class="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t to-transparent from-n-background flex items-end justify-center pb-2"
> >
<woot-button <NextButton
v-if="shrink" :label="$t('SEARCH.READ_MORE')"
size="tiny" icon="i-lucide-chevrons-down"
variant="smooth" blue
color-scheme="primary" xs
faded
class="backdrop-filter backdrop-blur-[2px]"
@click.prevent="$emit('expand')" @click.prevent="$emit('expand')"
> />
{{ $t('SEARCH.READ_MORE') }}
</woot-button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,52 +1,51 @@
<script> <script setup>
export default { import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
emits: ['search'], import { debounce } from '@chatwoot/utils';
data() { const emit = defineEmits(['search']);
return {
searchQuery: '', const searchQuery = ref('');
isInputFocused: false, const isInputFocused = ref(false);
};
}, const searchInput = useTemplateRef('searchInput');
mounted() {
this.$refs.searchInput.focus(); const handler = e => {
document.addEventListener('keydown', this.handler); if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
}, e.preventDefault();
unmounted() { searchInput.value.focus();
document.removeEventListener('keydown', this.handler); } else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
}, e.preventDefault();
methods: { searchInput.value.blur();
handler(e) { }
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
this.$refs.searchInput.focus();
} else if (
e.key === 'Escape' &&
document.activeElement.tagName === 'INPUT'
) {
e.preventDefault();
this.$refs.searchInput.blur();
}
},
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);
} else {
this.$emit('search', '');
}
}, 500);
},
onFocus() {
this.isInputFocused = true;
},
onBlur() {
this.isInputFocused = false;
},
},
}; };
const debouncedEmit = debounce(
value =>
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
500
);
const onInput = e => {
searchQuery.value = e.target.value;
debouncedEmit(searchQuery.value);
};
const onFocus = () => {
isInputFocused.value = true;
};
const onBlur = () => {
isInputFocused.value = false;
};
onMounted(() => {
searchInput.value.focus();
document.addEventListener('keydown', handler);
});
onUnmounted(() => {
document.removeEventListener('keydown', handler);
});
</script> </script>
<template> <template>
@@ -76,7 +75,7 @@ export default {
:value="searchQuery" :value="searchQuery"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@input="debounceSearch" @input="onInput"
/> />
<woot-label <woot-label
:title="$t('SEARCH.PLACEHOLDER_KEYBINDING')" :title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"

View File

@@ -1,35 +0,0 @@
<template>
<div class="search-input-box">
<woot-button
class="hollow"
size="small"
color-scheme="secondary"
is-expanded
>
<div class="search-input">
<fluent-icon icon="search" size="14px" class="search--icon" />
<span
class="overflow-hidden text-ellipsis whitespace-nowrap search-placeholder"
>
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
</span>
</div>
</woot-button>
</div>
</template>
<style lang="scss" scoped>
.search-input-box {
@apply p-2;
}
.search--icon {
@apply flex-shrink-0 text-slate-500 dark:text-slate-300;
}
.search-placeholder {
@apply text-slate-500 dark:text-slate-300;
}
.search-input {
@apply flex justify-center items-center gap-1;
}
</style>

View File

@@ -1,6 +1,8 @@
<script setup> <script setup>
import { frontendURL } from 'dashboard/helper/URLHelper';
import { computed } from 'vue'; import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -37,28 +39,27 @@ const navigateTo = computed(() => {
<template> <template>
<router-link <router-link
:to="navigateTo" :to="navigateTo"
class="flex items-center p-2 rounded-md cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3" class="flex items-start p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
> >
<woot-thumbnail :src="thumbnail" :username="name" size="24px" /> <Avatar
<div class="ml-2 rtl:mr-2 rtl:ml-0"> :name="name"
<h5 class="text-sm name text-n-slate-12 dark:text-n-slate-12"> :src="thumbnail"
:size="24"
rounded-full
class="mt-0.5"
/>
<div class="ml-2 rtl:mr-2 min-w-0 rtl:ml-0">
<h5 class="text-sm name truncate min-w-0 text-n-slate-12">
{{ name }} {{ name }}
</h5> </h5>
<p <p
class="flex items-center gap-1 m-0 text-sm text-slate-600 dark:text-slate-200" class="grid items-center m-0 gap-1 text-sm grid-cols-[minmax(0,1fr)_auto_auto]"
> >
<span v-if="email" class="email text-n-slate-12 dark:text-n-slate-12">{{ <span v-if="email" class="truncate text-n-slate-12" :title="email">
email {{ email }}
}}</span>
<span
v-if="phone"
class="separator text-n-slate-10 dark:text-n-slate-10"
>
</span>
<span v-if="phone" class="phone text-n-slate-12 dark:text-n-slate-12">
{{ phone }}
</span> </span>
<span v-if="phone" class="text-n-slate-10"></span>
<span v-if="phone" class="text-n-slate-12">{{ phone }}</span>
</p> </p>
</div> </div>
</router-link> </router-link>

View File

@@ -1,38 +1,29 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue'; import SearchResultSection from './SearchResultSection.vue';
import SearchResultContactItem from './SearchResultContactItem.vue'; import SearchResultContactItem from './SearchResultContactItem.vue';
export default { defineProps({
components: { contacts: {
SearchResultSection, type: Array,
SearchResultContactItem, default: () => [],
}, },
props: { query: {
contacts: { type: String,
type: Array, default: '',
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
}, },
computed: { isFetching: {
...mapGetters({ type: Boolean,
accountId: 'getCurrentAccountId', default: false,
}),
}, },
}; showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script> </script>
<template> <template>
@@ -43,7 +34,7 @@ export default {
:show-title="showTitle" :show-title="showTitle"
:is-fetching="isFetching" :is-fetching="isFetching"
> >
<ul v-if="contacts.length" class="space-y-1.5"> <ul v-if="contacts.length" class="space-y-1.5 list-none">
<li v-for="contact in contacts" :key="contact.id"> <li v-for="contact in contacts" :key="contact.id">
<SearchResultContactItem <SearchResultContactItem
:id="contact.id" :id="contact.id"

View File

@@ -1,72 +1,69 @@
<script> <script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js'; import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper'; import { dynamicTime } from 'shared/helpers/timeHelper';
import InboxName from 'dashboard/components/widgets/InboxName.vue'; import InboxName from 'dashboard/components/widgets/InboxName.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
export default { const props = defineProps({
components: { id: {
InboxName, type: Number,
default: 0,
}, },
props: { inbox: {
id: { type: Object,
type: Number, default: () => ({}),
default: 0,
},
inbox: {
type: Object,
default: () => ({}),
},
name: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
}, },
computed: { name: {
navigateTo() { type: String,
const params = {}; default: '',
if (this.messageId) {
params.messageId = this.messageId;
}
return frontendURL(
`accounts/${this.accountId}/conversations/${this.id}`,
params
);
},
createdAtTime() {
return dynamicTime(this.createdAt);
},
}, },
}; email: {
type: String,
default: '',
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
});
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
params.messageId = props.messageId;
}
return frontendURL(
`accounts/${props.accountId}/conversations/${props.id}`,
params
);
});
const createdAtTime = dynamicTime(props.createdAt);
</script> </script>
<template> <template>
<router-link <router-link
:to="navigateTo" :to="navigateTo"
class="flex p-2 rounded-md cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3" class="flex p-2 rounded-xl cursor-pointer hover:bg-n-slate-2"
> >
<div <Avatar
class="flex items-center justify-center flex-shrink-0 w-6 h-6 rounded bg-n-brand/10 dark:bg-n-brand/40 text-n-blue-text dark:text-n-blue-text" name="chats"
> :size="24"
<fluent-icon icon="chat-multiple" :size="14" /> icon-name="i-lucide-messages-square"
</div> class="[&>span]:rounded"
/>
<div class="flex-grow min-w-0 ml-2"> <div class="flex-grow min-w-0 ml-2">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center min-w-0 justify-between gap-1 mb-1">
<div class="flex"> <div class="flex">
<woot-label <woot-label
class="!bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong m-0" class="!bg-n-slate-3 dark:!bg-n-solid-3 !border-n-weak dark:!border-n-strong m-0"
@@ -83,29 +80,25 @@ export default {
/> />
</div> </div>
</div> </div>
<div> <span
<span class="text-xs font-normal min-w-0 truncate text-n-slate-11 dark:text-n-slate-11"
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11" >
> {{ createdAtTime }}
{{ createdAtTime }} </span>
</span>
</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<h5 <h5
v-if="name" v-if="name"
class="m-0 text-sm text-n-slate-12 dark:text-n-slate-12" class="m-0 text-sm min-w-0 truncate text-n-slate-12 dark:text-n-slate-12"
> >
<span <span class="text-xs font-norma text-n-slate-11 dark:text-n-slate-11">
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
{{ $t('SEARCH.FROM') }}: {{ $t('SEARCH.FROM') }}:
</span> </span>
{{ name }} {{ name }}
</h5> </h5>
<h5 <h5
v-if="email" v-if="email"
class="m-0 overflow-hidden text-sm text-n-slate-12 dark:text-n-slate-12 whitespace-nowrap text-ellipsis" class="m-0 overflow-hidden text-sm text-n-slate-12 dark:text-n-slate-12 truncate"
> >
<span <span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11" class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"

View File

@@ -1,37 +1,28 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue'; import SearchResultSection from './SearchResultSection.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue'; import SearchResultConversationItem from './SearchResultConversationItem.vue';
export default { defineProps({
components: { conversations: {
SearchResultSection, type: Array,
SearchResultConversationItem, default: () => [],
}, },
props: { query: {
conversations: { type: String,
type: Array, default: '',
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
}, },
computed: { isFetching: {
...mapGetters({ type: Boolean,
accountId: 'getCurrentAccountId', default: false,
}),
}, },
}; showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script> </script>
<template> <template>
@@ -39,10 +30,10 @@ export default {
:title="$t('SEARCH.SECTION.CONVERSATIONS')" :title="$t('SEARCH.SECTION.CONVERSATIONS')"
:empty="!conversations.length" :empty="!conversations.length"
:query="query" :query="query"
:show-title="showTitle || isFetching" :show-title="showTitle"
:is-fetching="isFetching" :is-fetching="isFetching"
> >
<ul v-if="conversations.length" class="space-y-1.5"> <ul v-if="conversations.length" class="space-y-1.5 list-none">
<li v-for="conversation in conversations" :key="conversation.id"> <li v-for="conversation in conversations" :key="conversation.id">
<SearchResultConversationItem <SearchResultConversationItem
:id="conversation.id" :id="conversation.id"

View File

@@ -1,45 +1,37 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultConversationItem from './SearchResultConversationItem.vue'; import SearchResultConversationItem from './SearchResultConversationItem.vue';
import SearchResultSection from './SearchResultSection.vue'; import SearchResultSection from './SearchResultSection.vue';
import MessageContent from './MessageContent.vue'; import MessageContent from './MessageContent.vue';
export default { defineProps({
components: { messages: {
SearchResultConversationItem, type: Array,
SearchResultSection, default: () => [],
MessageContent,
}, },
props: { query: {
messages: { type: String,
type: Array, default: '',
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
}, },
computed: { isFetching: {
...mapGetters({ type: Boolean,
accountId: 'getCurrentAccountId', default: false,
}),
}, },
methods: { showTitle: {
getName(message) { type: Boolean,
return message && message.sender && message.sender.name default: true,
? message.sender.name
: this.$t('SEARCH.BOT_LABEL');
},
}, },
});
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const getName = message => {
return message && message.sender && message.sender.name
? message.sender.name
: t('SEARCH.BOT_LABEL');
}; };
</script> </script>
@@ -51,7 +43,7 @@ export default {
:show-title="showTitle" :show-title="showTitle"
:is-fetching="isFetching" :is-fetching="isFetching"
> >
<ul v-if="messages.length" class="space-y-1.5"> <ul v-if="messages.length" class="space-y-1.5 list-none">
<li v-for="message in messages" :key="message.id"> <li v-for="message in messages" :key="message.id">
<SearchResultConversationItem <SearchResultConversationItem
:id="message.conversation_id" :id="message.conversation_id"

View File

@@ -32,14 +32,14 @@ const titleCase = computed(() => props.title.toLowerCase());
<div v-if="showTitle" class="sticky top-0 p-2 z-50 mb-0.5 bg-n-background"> <div v-if="showTitle" class="sticky top-0 p-2 z-50 mb-0.5 bg-n-background">
<h3 class="text-sm text-n-slate-12">{{ title }}</h3> <h3 class="text-sm text-n-slate-12">{{ title }}</h3>
</div> </div>
<slot />
<woot-loading-state <woot-loading-state
v-if="isFetching" v-if="isFetching"
:message="$t('SEARCH.SEARCHING_DATA')" :message="empty ? $t('SEARCH.SEARCHING_DATA') : $t('SEARCH.LOADING_DATA')"
/> />
<slot v-else />
<div <div
v-if="empty && !isFetching" v-if="empty && !isFetching"
class="flex items-center justify-center px-4 py-6 m-2 rounded-md bg-n-slate-2 dark:bg-n-solid-3" class="flex items-center justify-center px-4 py-6 m-2 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
> >
<fluent-icon icon="info" size="16px" class="text-n-slate-11" /> <fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="mx-2 my-0 text-center text-n-slate-11"> <p class="mx-2 my-0 text-center text-n-slate-11">

View File

@@ -1,39 +1,38 @@
<script> <script setup>
export default { import { ref, watch } from 'vue';
props: {
tabs: { const props = defineProps({
type: Array, tabs: {
default: () => [], type: Array,
}, default: () => [],
selectedTab: {
type: Number,
default: 0,
},
}, },
emits: ['tabChange'], selectedTab: {
data() { type: Number,
return { default: 0,
activeTab: 0,
};
},
watch: {
selectedTab(value, oldValue) {
if (value !== oldValue) {
this.activeTab = this.selectedTab;
}
},
},
methods: {
onTabChange(index) {
this.activeTab = index;
this.$emit('tabChange', this.tabs[index].key);
},
}, },
});
const emit = defineEmits(['tabChange']);
const activeTab = ref(props.selectedTab);
watch(
() => props.selectedTab,
(value, oldValue) => {
if (value !== oldValue) {
activeTab.value = props.selectedTab;
}
}
);
const onTabChange = index => {
activeTab.value = index;
emit('tabChange', props.tabs[index].key);
}; };
</script> </script>
<template> <template>
<div class="tab-container"> <div class="mt-1 border-b border-n-weak">
<woot-tabs :index="activeTab" :border="false" @change="onTabChange"> <woot-tabs :index="activeTab" :border="false" @change="onTabChange">
<woot-tabs-item <woot-tabs-item
v-for="(item, index) in tabs" v-for="(item, index) in tabs"
@@ -41,13 +40,8 @@ export default {
:index="index" :index="index"
:name="item.name" :name="item.name"
:count="item.count" :count="item.count"
:show-badge="item.showBadge"
/> />
</woot-tabs> </woot-tabs>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.tab-container {
@apply mt-1 border-b border-solid border-slate-100 dark:border-slate-800/50;
}
</style>

View File

@@ -1,12 +1,9 @@
<script> <script setup>
import SearchHeader from './SearchHeader.vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import SearchTabs from './SearchTabs.vue'; import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import SearchResultConversationsList from './SearchResultConversationsList.vue'; import { useRouter } from 'vue-router';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import Policy from 'dashboard/components/policy.vue'; import { useI18n } from 'vue-i18n';
import { import {
ROLES, ROLES,
CONVERSATION_PERMISSIONS, CONVERSATION_PERMISSIONS,
@@ -16,197 +13,233 @@ import {
getUserPermissions, getUserPermissions,
filterItemsByPermission, filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js'; } from 'dashboard/helper/permissionsHelper.js';
import { mapGetters } from 'vuex';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default { import Policy from 'dashboard/components/policy.vue';
components: { import NextButton from 'dashboard/components-next/button/Button.vue';
SearchHeader, import SearchHeader from './SearchHeader.vue';
SearchTabs, import SearchTabs from './SearchTabs.vue';
SearchResultContactsList, import SearchResultConversationsList from './SearchResultConversationsList.vue';
SearchResultConversationsList, import SearchResultMessagesList from './SearchResultMessagesList.vue';
SearchResultMessagesList, import SearchResultContactsList from './SearchResultContactsList.vue';
Policy,
ButtonV4,
},
data() {
return {
selectedTab: 'all',
query: '',
contactPermissions: CONTACT_PERMISSIONS,
conversationPermissions: CONVERSATION_PERMISSIONS,
rolePermissions: ROLES,
};
},
computed: { const router = useRouter();
...mapGetters({ const store = useStore();
currentUser: 'getCurrentUser', const { t } = useI18n();
currentAccountId: 'getCurrentAccountId',
contactRecords: 'conversationSearch/getContactRecords',
conversationRecords: 'conversationSearch/getConversationRecords',
messageRecords: 'conversationSearch/getMessageRecords',
uiFlags: 'conversationSearch/getUIFlags',
}),
contacts() {
return this.contactRecords.map(contact => ({
...contact,
type: 'contact',
}));
},
conversations() {
return this.conversationRecords.map(conversation => ({
...conversation,
type: 'conversation',
}));
},
messages() {
return this.messageRecords.map(message => ({
...message,
type: 'message',
}));
},
all() {
return [...this.contacts, ...this.conversations, ...this.messages];
},
filterContacts() {
return this.selectedTab === 'contacts' || this.isSelectedTabAll;
},
filterConversations() {
return this.selectedTab === 'conversations' || this.isSelectedTabAll;
},
filterMessages() {
return this.selectedTab === 'messages' || this.isSelectedTabAll;
},
userPermissions() {
return getUserPermissions(this.currentUser, this.currentAccountId);
},
totalSearchResultsCount() {
const permissionCounts = {
contacts: {
permissions: [...this.rolePermissions, this.contactPermissions],
count: () => this.contacts.length,
},
conversations: {
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
count: () => this.conversations.length + this.messages.length,
},
};
const filteredCounts = filterItemsByPermission( const PER_PAGE = 15; // Results per page
permissionCounts, const selectedTab = ref('all');
this.userPermissions, const query = ref('');
item => item.permissions, const pages = ref({
(_, item) => item.count contacts: 1,
); conversations: 1,
messages: 1,
});
return filteredCounts.reduce((total, count) => total + count(), 0); const currentUser = useMapGetter('getCurrentUser');
}, const currentAccountId = useMapGetter('getCurrentAccountId');
tabs() { const contactRecords = useMapGetter('conversationSearch/getContactRecords');
const allTabsConfig = { const conversationRecords = useMapGetter(
all: { 'conversationSearch/getConversationRecords'
key: 'all', );
name: this.$t('SEARCH.TABS.ALL'), const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
count: this.totalSearchResultsCount, const uiFlags = useMapGetter('conversationSearch/getUIFlags');
permissions: [
this.contactPermissions,
...this.rolePermissions,
...this.conversationPermissions,
],
},
contacts: {
key: 'contacts',
name: this.$t('SEARCH.TABS.CONTACTS'),
count: this.contacts.length,
permissions: [...this.rolePermissions, this.contactPermissions],
},
conversations: {
key: 'conversations',
name: this.$t('SEARCH.TABS.CONVERSATIONS'),
count: this.conversations.length,
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
},
messages: {
key: 'messages',
name: this.$t('SEARCH.TABS.MESSAGES'),
count: this.messages.length,
permissions: [
...this.rolePermissions,
...this.conversationPermissions,
],
},
};
return filterItemsByPermission( const addTypeToRecords = (records, type) =>
allTabsConfig, records.value.map(item => ({ ...item, type }));
this.userPermissions,
item => item.permissions const mappedContacts = computed(() =>
); addTypeToRecords(contactRecords, 'contact')
}, );
activeTabIndex() { const mappedConversations = computed(() =>
const index = this.tabs.findIndex(tab => tab.key === this.selectedTab); addTypeToRecords(conversationRecords, 'conversation')
return index >= 0 ? index : 0; );
}, const mappedMessages = computed(() =>
showEmptySearchResults() { addTypeToRecords(messageRecords, 'message')
return ( );
this.totalSearchResultsCount === 0 &&
this.uiFlags.isSearchCompleted && const isSelectedTabAll = computed(() => selectedTab.value === 'all');
!this.uiFlags.isFetching &&
this.query const sliceRecordsIfAllTab = items =>
); isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
},
showResultsSection() { const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
return ( const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
(this.uiFlags.isSearchCompleted && const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
this.totalSearchResultsCount !== 0) ||
this.uiFlags.isFetching const filterByTab = tab =>
); computed(() => selectedTab.value === tab || isSelectedTabAll.value);
},
isSelectedTabAll() { const filterContacts = filterByTab('contacts');
return this.selectedTab === 'all'; const filterConversations = filterByTab('conversations');
}, const filterMessages = filterByTab('messages');
const userPermissions = computed(() =>
getUserPermissions(currentUser.value, currentAccountId.value)
);
const TABS_CONFIG = {
all: {
permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => null, // No count for all tab
}, },
unmounted() { contacts: {
this.query = ''; permissions: [...ROLES, CONTACT_PERMISSIONS],
this.$store.dispatch('conversationSearch/clearSearchResults'); count: () => mappedContacts.value.length,
}, },
mounted() { conversations: {
this.$store.dispatch('conversationSearch/clearSearchResults'); permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedConversations.value.length,
}, },
methods: { messages: {
onSearch(q) { permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
this.selectedTab = 'all'; count: () => mappedMessages.value.length,
this.query = q;
if (!q) {
this.$store.dispatch('conversationSearch/clearSearchResults');
return;
}
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
this.$store.dispatch('conversationSearch/fullSearch', { q });
},
onBack() {
if (window.history.length > 2) {
this.$router.go(-1);
} else {
this.$router.push({ name: 'home' });
}
},
}, },
}; };
const tabs = computed(() => {
const configs = Object.entries(TABS_CONFIG).map(([key, config]) => ({
key,
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
count: config.count(),
showBadge: key !== 'all',
permissions: config.permissions,
}));
return filterItemsByPermission(
configs,
userPermissions.value,
item => item.permissions
);
});
const totalSearchResultsCount = computed(() => {
const permissionCounts = {
contacts: {
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => contacts.value.length,
},
conversations: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length,
},
};
return filterItemsByPermission(
permissionCounts,
userPermissions.value,
item => item.permissions,
(_, item) => item.count
).reduce((total, count) => total + count(), 0);
});
const activeTabIndex = computed(() => {
const index = tabs.value.findIndex(tab => tab.key === selectedTab.value);
return index >= 0 ? index : 0;
});
const isFetchingAny = computed(() => {
const { contact, message, conversation, isFetching } = uiFlags.value;
return (
isFetching ||
contact.isFetching ||
message.isFetching ||
conversation.isFetching
);
});
const showEmptySearchResults = computed(
() =>
totalSearchResultsCount.value === 0 &&
uiFlags.value.isSearchCompleted &&
isSelectedTabAll.value &&
!isFetchingAny.value &&
query.value
);
const showResultsSection = computed(
() =>
(uiFlags.value.isSearchCompleted && totalSearchResultsCount.value !== 0) ||
isFetchingAny.value ||
(!isSelectedTabAll.value && query.value && !isFetchingAny.value)
);
const showLoadMore = computed(() => {
if (!query.value || isFetchingAny.value || selectedTab.value === 'all')
return false;
const records = {
contacts: mappedContacts.value,
conversations: mappedConversations.value,
messages: mappedMessages.value,
}[selectedTab.value];
return (
records?.length > 0 &&
records.length === pages.value[selectedTab.value] * PER_PAGE
);
});
const showViewMore = computed(() => ({
// Hide view more button if the number of records is less than 5
contacts: mappedContacts.value?.length > 5 && isSelectedTabAll.value,
conversations:
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
}));
const clearSearchResult = () => {
pages.value = { contacts: 1, conversations: 1, messages: 1 };
store.dispatch('conversationSearch/clearSearchResults');
};
const onSearch = q => {
query.value = q;
clearSearchResult();
if (!q) return;
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
store.dispatch('conversationSearch/fullSearch', { q, page: 1 });
};
const onBack = () => {
if (window.history.length > 2) {
router.go(-1);
} else {
router.push({ name: 'home' });
}
clearSearchResult();
};
const loadMore = () => {
const SEARCH_ACTIONS = {
contacts: 'conversationSearch/contactSearch',
conversations: 'conversationSearch/conversationSearch',
messages: 'conversationSearch/messageSearch',
};
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
const tab = selectedTab.value;
pages.value[tab] += 1;
store.dispatch(SEARCH_ACTIONS[tab], {
q: query.value,
page: pages.value[tab],
});
};
onMounted(() => {
store.dispatch('conversationSearch/clearSearchResults');
});
onUnmounted(() => {
query.value = '';
store.dispatch('conversationSearch/clearSearchResults');
});
</script> </script>
<template> <template>
<div class="flex flex-col w-full bg-n-background"> <div class="flex flex-col w-full h-full bg-n-background">
<div class="flex p-4"> <div class="flex w-full p-4">
<ButtonV4 <NextButton
:label="$t('GENERAL_SETTINGS.BACK')" :label="t('GENERAL_SETTINGS.BACK')"
icon="i-lucide-chevron-left" icon="i-lucide-chevron-left"
faded faded
primary primary
@@ -214,73 +247,119 @@ export default {
@click="onBack" @click="onBack"
/> />
</div> </div>
<section <section class="flex flex-col flex-grow w-full h-full overflow-hidden">
class="flex my-0 p-4 relative mx-auto max-w-[45rem] min-h-[20rem] flex-col w-full h-full bg-n-background" <div class="w-full max-w-4xl mx-auto">
> <div class="flex flex-col w-full px-4">
<header> <SearchHeader @search="onSearch" />
<SearchHeader @search="onSearch" /> <SearchTabs
<SearchTabs v-if="query"
v-if="query" :tabs="tabs"
:tabs="tabs" :selected-tab="activeTabIndex"
:selected-tab="activeTabIndex" @tab-change="tab => (selectedTab = tab)"
@tab-change="tab => (selectedTab = tab)" />
/>
</header>
<div class="flex-grow h-full px-2 py-0 overflow-y-auto">
<div v-if="showResultsSection">
<Policy :permissions="[...rolePermissions, contactPermissions]">
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
<Policy
:permissions="[...rolePermissions, ...conversationPermissions]"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
/>
</Policy>
</div> </div>
<div </div>
v-else-if="showEmptySearchResults" <div class="flex-grow w-full h-full overflow-y-auto">
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md" <div class="w-full max-w-4xl mx-auto px-4 pb-6">
> <div v-if="showResultsSection">
<fluent-icon icon="info" size="16px" class="text-n-slate-11" /> <Policy
<p class="m-2 text-center text-n-slate-11"> :permissions="[...ROLES, CONTACT_PERMISSIONS]"
{{ $t('SEARCH.EMPTY_STATE_FULL', { query }) }} class="flex flex-col justify-center"
</p> >
</div> <SearchResultContactsList
<div v-if="filterContacts"
v-else :is-fetching="uiFlags.contact.isFetching"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md" :contacts="contacts"
> :query="query"
<p class="text-center margin-bottom-0"> :show-title="isSelectedTabAll"
<fluent-icon icon="search" size="24px" class="text-n-slate-11" /> />
</p> <NextButton
<p class="m-2 text-center text-n-slate-11"> v-if="showViewMore.contacts"
{{ $t('SEARCH.EMPTY_STATE_DEFAULT') }} :label="t(`SEARCH.VIEW_MORE`)"
</p> icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'contacts'"
/>
</Policy>
<Policy
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.messages"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'messages'"
/>
</Policy>
<Policy
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.conversations"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'conversations'"
/>
</Policy>
<div v-if="showLoadMore" class="flex justify-center mt-4 mb-6">
<NextButton
v-if="!isSelectedTabAll"
:label="t(`SEARCH.LOAD_MORE`)"
icon="i-lucide-cloud-download"
slate
sm
faded
@click="loadMore"
/>
</div>
</div>
<div
v-else-if="showEmptySearchResults"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="m-2 text-center text-n-slate-11">
{{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
</p>
</div>
<div
v-else-if="!query"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
>
<p class="text-center margin-bottom-0">
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
</p>
<p class="m-2 text-center text-n-slate-11">
{{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
</p>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -75,11 +75,10 @@ export const actions = {
}); });
} }
}, },
async contactSearch({ commit }, { q }) { async contactSearch({ commit }, { q, page = 1 }) {
commit(types.CONTACT_SEARCH_SET, []);
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true }); commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true });
try { try {
const { data } = await SearchAPI.contacts({ q }); const { data } = await SearchAPI.contacts({ q, page });
commit(types.CONTACT_SEARCH_SET, data.payload.contacts); commit(types.CONTACT_SEARCH_SET, data.payload.contacts);
} catch (error) { } catch (error) {
// Ignore error // Ignore error
@@ -87,11 +86,10 @@ export const actions = {
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false }); commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false });
} }
}, },
async conversationSearch({ commit }, { q }) { async conversationSearch({ commit }, { q, page = 1 }) {
commit(types.CONVERSATION_SEARCH_SET, []);
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true }); commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true });
try { try {
const { data } = await SearchAPI.conversations({ q }); const { data } = await SearchAPI.conversations({ q, page });
commit(types.CONVERSATION_SEARCH_SET, data.payload.conversations); commit(types.CONVERSATION_SEARCH_SET, data.payload.conversations);
} catch (error) { } catch (error) {
// Ignore error // Ignore error
@@ -99,11 +97,10 @@ export const actions = {
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false }); commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false });
} }
}, },
async messageSearch({ commit }, { q }) { async messageSearch({ commit }, { q, page = 1 }) {
commit(types.MESSAGE_SEARCH_SET, []);
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true }); commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true });
try { try {
const { data } = await SearchAPI.messages({ q }); const { data } = await SearchAPI.messages({ q, page });
commit(types.MESSAGE_SEARCH_SET, data.payload.messages); commit(types.MESSAGE_SEARCH_SET, data.payload.messages);
} catch (error) { } catch (error) {
// Ignore error // Ignore error
@@ -112,9 +109,7 @@ export const actions = {
} }
}, },
async clearSearchResults({ commit }) { async clearSearchResults({ commit }) {
commit(types.MESSAGE_SEARCH_SET, []); commit(types.CLEAR_SEARCH_RESULTS);
commit(types.CONVERSATION_SEARCH_SET, []);
commit(types.CONTACT_SEARCH_SET, []);
}, },
}; };
@@ -123,13 +118,13 @@ export const mutations = {
state.records = records; state.records = records;
}, },
[types.CONTACT_SEARCH_SET](state, records) { [types.CONTACT_SEARCH_SET](state, records) {
state.contactRecords = records; state.contactRecords = [...state.contactRecords, ...records];
}, },
[types.CONVERSATION_SEARCH_SET](state, records) { [types.CONVERSATION_SEARCH_SET](state, records) {
state.conversationRecords = records; state.conversationRecords = [...state.conversationRecords, ...records];
}, },
[types.MESSAGE_SEARCH_SET](state, records) { [types.MESSAGE_SEARCH_SET](state, records) {
state.messageRecords = records; state.messageRecords = [...state.messageRecords, ...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 };
@@ -146,6 +141,11 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) { [types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags }; state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
}, },
[types.CLEAR_SEARCH_RESULTS](state) {
state.contactRecords = [];
state.conversationRecords = [];
state.messageRecords = [];
},
}; };
export default { export default {

View File

@@ -1,11 +1,19 @@
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';
const commit = vi.fn(); const commit = vi.fn();
const dispatch = vi.fn();
global.axios = axios; global.axios = axios;
vi.mock('axios'); vi.mock('axios');
describe('#actions', () => { describe('#actions', () => {
beforeEach(() => {
commit.mockClear();
dispatch.mockClear();
axios.get.mockClear();
});
describe('#get', () => { describe('#get', () => {
it('sends correct actions if no query param is provided', () => { it('sends correct actions if no query param is provided', () => {
actions.get({ commit }, { q: '' }); actions.get({ commit }, { q: '' });
@@ -41,4 +49,111 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#fullSearch', () => {
it('should not dispatch any actions if no query provided', async () => {
await actions.fullSearch({ commit, dispatch }, { q: '' });
expect(dispatch).not.toHaveBeenCalled();
});
it('should dispatch all search actions and set UI flags correctly', async () => {
await actions.fullSearch({ commit, dispatch }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[
types.FULL_SEARCH_SET_UI_FLAG,
{ isFetching: true, isSearchCompleted: false },
],
[
types.FULL_SEARCH_SET_UI_FLAG,
{ isFetching: false, isSearchCompleted: true },
],
]);
expect(dispatch).toHaveBeenCalledWith('contactSearch', { q: 'test' });
expect(dispatch).toHaveBeenCalledWith('conversationSearch', {
q: 'test',
});
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
});
});
describe('#contactSearch', () => {
it('should handle successful contact search', async () => {
axios.get.mockResolvedValue({
data: { payload: { contacts: [{ id: 1 }] } },
});
await actions.contactSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.CONTACT_SEARCH_SET, [{ id: 1 }]],
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed contact search', async () => {
axios.get.mockRejectedValue({});
await actions.contactSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#conversationSearch', () => {
it('should handle successful conversation search', async () => {
axios.get.mockResolvedValue({
data: { payload: { conversations: [{ id: 1 }] } },
});
await actions.conversationSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.CONVERSATION_SEARCH_SET, [{ id: 1 }]],
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed conversation search', async () => {
axios.get.mockRejectedValue({});
await actions.conversationSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#messageSearch', () => {
it('should handle successful message search', async () => {
axios.get.mockResolvedValue({
data: { payload: { messages: [{ id: 1 }] } },
});
await actions.messageSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.MESSAGE_SEARCH_SET, [{ id: 1 }]],
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed message search', async () => {
axios.get.mockRejectedValue({});
await actions.messageSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#clearSearchResults', () => {
it('should commit clear search results mutation', () => {
actions.clearSearchResults({ commit });
expect(commit).toHaveBeenCalledWith(types.CLEAR_SEARCH_RESULTS);
});
});
}); });

View File

@@ -10,10 +10,49 @@ describe('#getters', () => {
]); ]);
}); });
it('getContactRecords', () => {
const state = {
contactRecords: [{ id: 1, name: 'Contact 1' }],
};
expect(getters.getContactRecords(state)).toEqual([
{ id: 1, name: 'Contact 1' },
]);
});
it('getConversationRecords', () => {
const state = {
conversationRecords: [{ id: 1, title: 'Conversation 1' }],
};
expect(getters.getConversationRecords(state)).toEqual([
{ id: 1, title: 'Conversation 1' },
]);
});
it('getMessageRecords', () => {
const state = {
messageRecords: [{ id: 1, content: 'Message 1' }],
};
expect(getters.getMessageRecords(state)).toEqual([
{ id: 1, content: 'Message 1' },
]);
});
it('getUIFlags', () => { it('getUIFlags', () => {
const state = { const state = {
uiFlags: { isFetching: false }, uiFlags: {
isFetching: false,
isSearchCompleted: true,
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
},
}; };
expect(getters.getUIFlags(state)).toEqual({ isFetching: false }); expect(getters.getUIFlags(state)).toEqual({
isFetching: false,
isSearchCompleted: true,
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
});
}); });
}); });

View File

@@ -10,7 +10,7 @@ describe('#mutations', () => {
}); });
}); });
describe('#SEARCH_CONVERSATIONS_SET', () => { describe('#SEARCH_CONVERSATIONS_SET_UI_FLAG', () => {
it('set uiFlags correctly', () => { it('set uiFlags correctly', () => {
const state = { uiFlags: { isFetching: true } }; const state = { uiFlags: { isFetching: true } };
mutations[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, { mutations[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, {
@@ -19,4 +19,99 @@ describe('#mutations', () => {
expect(state.uiFlags).toEqual({ isFetching: false }); expect(state.uiFlags).toEqual({ isFetching: false });
}); });
}); });
describe('#CONTACT_SEARCH_SET', () => {
it('should append new contact records to existing ones', () => {
const state = { contactRecords: [{ id: 1 }] };
mutations[types.CONTACT_SEARCH_SET](state, [{ id: 2 }]);
expect(state.contactRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#CONVERSATION_SEARCH_SET', () => {
it('should append new conversation records to existing ones', () => {
const state = { conversationRecords: [{ id: 1 }] };
mutations[types.CONVERSATION_SEARCH_SET](state, [{ id: 2 }]);
expect(state.conversationRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#MESSAGE_SEARCH_SET', () => {
it('should append new message records to existing ones', () => {
const state = { messageRecords: [{ id: 1 }] };
mutations[types.MESSAGE_SEARCH_SET](state, [{ id: 2 }]);
expect(state.messageRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#FULL_SEARCH_SET_UI_FLAG', () => {
it('set full search UI flags correctly', () => {
const state = {
uiFlags: {
isFetching: true,
isSearchCompleted: false,
},
};
mutations[types.FULL_SEARCH_SET_UI_FLAG](state, {
isFetching: false,
isSearchCompleted: true,
});
expect(state.uiFlags).toEqual({
isFetching: false,
isSearchCompleted: true,
});
});
});
describe('#CONTACT_SEARCH_SET_UI_FLAG', () => {
it('set contact search UI flags correctly', () => {
const state = {
uiFlags: {
contact: { isFetching: true },
},
};
mutations[types.CONTACT_SEARCH_SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags.contact).toEqual({ isFetching: false });
});
});
describe('#CONVERSATION_SEARCH_SET_UI_FLAG', () => {
it('set conversation search UI flags correctly', () => {
const state = {
uiFlags: {
conversation: { isFetching: true },
},
};
mutations[types.CONVERSATION_SEARCH_SET_UI_FLAG](state, {
isFetching: false,
});
expect(state.uiFlags.conversation).toEqual({ isFetching: false });
});
});
describe('#MESSAGE_SEARCH_SET_UI_FLAG', () => {
it('set message search UI flags correctly', () => {
const state = {
uiFlags: {
message: { isFetching: true },
},
};
mutations[types.MESSAGE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags.message).toEqual({ isFetching: false });
});
});
describe('#CLEAR_SEARCH_RESULTS', () => {
it('should clear all search records', () => {
const state = {
contactRecords: [{ id: 1 }],
conversationRecords: [{ id: 1 }],
messageRecords: [{ id: 1 }],
};
mutations[types.CLEAR_SEARCH_RESULTS](state);
expect(state.contactRecords).toEqual([]);
expect(state.conversationRecords).toEqual([]);
expect(state.messageRecords).toEqual([]);
});
});
}); });

View File

@@ -311,6 +311,7 @@ export default {
CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET', CONVERSATION_SEARCH_SET: 'CONVERSATION_SEARCH_SET',
CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG', CONVERSATION_SEARCH_SET_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET', MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG', MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG', FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
SET_CONVERSATION_PARTICIPANTS_UI_FLAG: SET_CONVERSATION_PARTICIPANTS_UI_FLAG:

View File

@@ -30,7 +30,8 @@ class SearchService
.where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email .where("cast(conversations.display_id as text) ILIKE :search OR contacts.name ILIKE :search OR contacts.email
ILIKE :search OR contacts.phone_number ILIKE :search OR contacts.identifier ILIKE :search", search: "%#{search_query}%") ILIKE :search OR contacts.phone_number ILIKE :search OR contacts.identifier ILIKE :search", search: "%#{search_query}%")
.order('conversations.created_at DESC') .order('conversations.created_at DESC')
.limit(10) .page(params[:page])
.per(15)
end end
def filter_messages def filter_messages
@@ -38,13 +39,14 @@ class SearchService
.where('messages.content ILIKE :search', search: "%#{search_query}%") .where('messages.content ILIKE :search', search: "%#{search_query}%")
.where('created_at >= ?', 3.months.ago) .where('created_at >= ?', 3.months.ago)
.reorder('created_at DESC') .reorder('created_at DESC')
.limit(10) .page(params[:page])
.per(15)
end end
def filter_contacts def filter_contacts
@contacts = current_account.contacts.where( @contacts = current_account.contacts.where(
"name ILIKE :search OR email ILIKE :search OR phone_number "name ILIKE :search OR email ILIKE :search OR phone_number
ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%" ILIKE :search OR identifier ILIKE :search", search: "%#{search_query}%"
).resolved_contacts.order_on_last_activity_at('desc').limit(10) ).resolved_contacts.order_on_last_activity_at('desc').page(params[:page]).per(15)
end end
end end