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`, {
params: {
q,
page: page,
},
});
}
conversations({ q }) {
conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
},
});
}
messages({ q }) {
messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
},
});
}

View File

@@ -11,7 +11,10 @@
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
},
"VIEW_MORE": "View more",
"LOAD_MORE": "Load more",
"SEARCHING_DATA": "Searching",
"LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'",
"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 ReadMore from './ReadMore.vue';
export default {
components: {
ReadMore,
const props = defineProps({
author: {
type: String,
default: '',
},
props: {
author: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
setup() {
const { formatMessage, highlightContent } = useMessageFormatter();
return {
formatMessage,
highlightContent,
};
searchTerm: {
type: String,
default: '',
},
data() {
return {
isOverflowing: false,
};
},
computed: {
messageContent() {
return this.formatMessage(this.content);
},
},
mounted() {
this.$watch(() => {
return this.$refs.messageContainer;
}, this.setOverflow);
});
this.$nextTick(this.setOverflow);
},
methods: {
setOverflow() {
const wrap = this.$refs.messageContainer;
if (wrap) {
const message = wrap.querySelector('.message-content');
this.isOverflowing = 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 { highlightContent } = useMessageFormatter();
const messageContainer = useTemplateRef('messageContainer');
const isOverflowing = ref(false);
const setOverflow = () => {
const wrap = messageContainer.value;
if (wrap) {
const message = wrap.querySelector('.message-content');
isOverflowing.value = message.offsetHeight > 150;
}
};
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>
<template>

View File

@@ -1,4 +1,6 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
shrink: {
type: Boolean,
@@ -18,17 +20,18 @@ defineEmits(['expand']);
>
<slot />
<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
v-if="shrink"
size="tiny"
variant="smooth"
color-scheme="primary"
<NextButton
:label="$t('SEARCH.READ_MORE')"
icon="i-lucide-chevrons-down"
blue
xs
faded
class="backdrop-filter backdrop-blur-[2px]"
@click.prevent="$emit('expand')"
>
{{ $t('SEARCH.READ_MORE') }}
</woot-button>
/>
</div>
</div>
</div>

View File

@@ -1,52 +1,51 @@
<script>
export default {
emits: ['search'],
<script setup>
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { debounce } from '@chatwoot/utils';
data() {
return {
searchQuery: '',
isInputFocused: false,
};
},
mounted() {
this.$refs.searchInput.focus();
document.addEventListener('keydown', this.handler);
},
unmounted() {
document.removeEventListener('keydown', this.handler);
},
methods: {
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 emit = defineEmits(['search']);
const searchQuery = ref('');
const isInputFocused = ref(false);
const searchInput = useTemplateRef('searchInput');
const handler = e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchInput.value.focus();
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
e.preventDefault();
searchInput.value.blur();
}
};
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>
<template>
@@ -76,7 +75,7 @@ export default {
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="debounceSearch"
@input="onInput"
/>
<woot-label
: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>
import { frontendURL } from 'dashboard/helper/URLHelper';
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
id: {
@@ -37,28 +39,27 @@ const navigateTo = computed(() => {
<template>
<router-link
: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" />
<div class="ml-2 rtl:mr-2 rtl:ml-0">
<h5 class="text-sm name text-n-slate-12 dark:text-n-slate-12">
<Avatar
:name="name"
: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 }}
</h5>
<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">{{
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 v-if="email" class="truncate text-n-slate-12" :title="email">
{{ email }}
</span>
<span v-if="phone" class="text-n-slate-10"></span>
<span v-if="phone" class="text-n-slate-12">{{ phone }}</span>
</p>
</div>
</router-link>

View File

@@ -1,38 +1,29 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultContactItem from './SearchResultContactItem.vue';
export default {
components: {
SearchResultSection,
SearchResultContactItem,
defineProps({
contacts: {
type: Array,
default: () => [],
},
props: {
contacts: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
};
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script>
<template>
@@ -43,7 +34,7 @@ export default {
:show-title="showTitle"
: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">
<SearchResultContactItem
:id="contact.id"

View File

@@ -1,72 +1,69 @@
<script>
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
export default {
components: {
InboxName,
const props = defineProps({
id: {
type: Number,
default: 0,
},
props: {
id: {
type: Number,
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,
},
inbox: {
type: Object,
default: () => ({}),
},
computed: {
navigateTo() {
const params = {};
if (this.messageId) {
params.messageId = this.messageId;
}
return frontendURL(
`accounts/${this.accountId}/conversations/${this.id}`,
params
);
},
createdAtTime() {
return dynamicTime(this.createdAt);
},
name: {
type: String,
default: '',
},
};
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>
<template>
<router-link
: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
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"
>
<fluent-icon icon="chat-multiple" :size="14" />
</div>
<Avatar
name="chats"
:size="24"
icon-name="i-lucide-messages-square"
class="[&>span]:rounded"
/>
<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">
<woot-label
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>
<span
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<span
class="text-xs font-normal min-w-0 truncate text-n-slate-11 dark:text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<div class="flex gap-2">
<h5
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
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
>
<span class="text-xs font-norma text-n-slate-11 dark:text-n-slate-11">
{{ $t('SEARCH.FROM') }}:
</span>
{{ name }}
</h5>
<h5
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
class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"

View File

@@ -1,37 +1,28 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
export default {
components: {
SearchResultSection,
SearchResultConversationItem,
defineProps({
conversations: {
type: Array,
default: () => [],
},
props: {
conversations: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
};
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script>
<template>
@@ -39,10 +30,10 @@ export default {
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
:empty="!conversations.length"
:query="query"
:show-title="showTitle || isFetching"
:show-title="showTitle"
: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">
<SearchResultConversationItem
:id="conversation.id"

View File

@@ -1,45 +1,37 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
import SearchResultSection from './SearchResultSection.vue';
import MessageContent from './MessageContent.vue';
export default {
components: {
SearchResultConversationItem,
SearchResultSection,
MessageContent,
defineProps({
messages: {
type: Array,
default: () => [],
},
props: {
messages: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
query: {
type: String,
default: '',
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
isFetching: {
type: Boolean,
default: false,
},
methods: {
getName(message) {
return message && message.sender && message.sender.name
? message.sender.name
: this.$t('SEARCH.BOT_LABEL');
},
showTitle: {
type: Boolean,
default: true,
},
});
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>
@@ -51,7 +43,7 @@ export default {
:show-title="showTitle"
: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">
<SearchResultConversationItem
: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">
<h3 class="text-sm text-n-slate-12">{{ title }}</h3>
</div>
<slot />
<woot-loading-state
v-if="isFetching"
:message="$t('SEARCH.SEARCHING_DATA')"
:message="empty ? $t('SEARCH.SEARCHING_DATA') : $t('SEARCH.LOADING_DATA')"
/>
<slot v-else />
<div
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" />
<p class="mx-2 my-0 text-center text-n-slate-11">

View File

@@ -1,39 +1,38 @@
<script>
export default {
props: {
tabs: {
type: Array,
default: () => [],
},
selectedTab: {
type: Number,
default: 0,
},
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
tabs: {
type: Array,
default: () => [],
},
emits: ['tabChange'],
data() {
return {
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);
},
selectedTab: {
type: Number,
default: 0,
},
});
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>
<template>
<div class="tab-container">
<div class="mt-1 border-b border-n-weak">
<woot-tabs :index="activeTab" :border="false" @change="onTabChange">
<woot-tabs-item
v-for="(item, index) in tabs"
@@ -41,13 +40,8 @@ export default {
:index="index"
:name="item.name"
:count="item.count"
:show-badge="item.showBadge"
/>
</woot-tabs>
</div>
</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>
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 ButtonV4 from 'dashboard/components-next/button/Button.vue';
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useTrack } from 'dashboard/composables';
import Policy from 'dashboard/components/policy.vue';
import { useI18n } from 'vue-i18n';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -16,197 +13,233 @@ import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { mapGetters } from 'vuex';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
components: {
SearchHeader,
SearchTabs,
SearchResultContactsList,
SearchResultConversationsList,
SearchResultMessagesList,
Policy,
ButtonV4,
},
data() {
return {
selectedTab: 'all',
query: '',
contactPermissions: CONTACT_PERMISSIONS,
conversationPermissions: CONVERSATION_PERMISSIONS,
rolePermissions: ROLES,
};
},
import Policy from 'dashboard/components/policy.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
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';
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
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 router = useRouter();
const store = useStore();
const { t } = useI18n();
const filteredCounts = filterItemsByPermission(
permissionCounts,
this.userPermissions,
item => item.permissions,
(_, item) => item.count
);
const PER_PAGE = 15; // Results per page
const selectedTab = ref('all');
const query = ref('');
const pages = ref({
contacts: 1,
conversations: 1,
messages: 1,
});
return filteredCounts.reduce((total, count) => total + count(), 0);
},
tabs() {
const allTabsConfig = {
all: {
key: 'all',
name: this.$t('SEARCH.TABS.ALL'),
count: this.totalSearchResultsCount,
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,
],
},
};
const currentUser = useMapGetter('getCurrentUser');
const currentAccountId = useMapGetter('getCurrentAccountId');
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords'
);
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
return filterItemsByPermission(
allTabsConfig,
this.userPermissions,
item => item.permissions
);
},
activeTabIndex() {
const index = this.tabs.findIndex(tab => tab.key === this.selectedTab);
return index >= 0 ? index : 0;
},
showEmptySearchResults() {
return (
this.totalSearchResultsCount === 0 &&
this.uiFlags.isSearchCompleted &&
!this.uiFlags.isFetching &&
this.query
);
},
showResultsSection() {
return (
(this.uiFlags.isSearchCompleted &&
this.totalSearchResultsCount !== 0) ||
this.uiFlags.isFetching
);
},
isSelectedTabAll() {
return this.selectedTab === 'all';
},
const addTypeToRecords = (records, type) =>
records.value.map(item => ({ ...item, type }));
const mappedContacts = computed(() =>
addTypeToRecords(contactRecords, 'contact')
);
const mappedConversations = computed(() =>
addTypeToRecords(conversationRecords, 'conversation')
);
const mappedMessages = computed(() =>
addTypeToRecords(messageRecords, 'message')
);
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
const sliceRecordsIfAllTab = items =>
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
const filterByTab = tab =>
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
const filterContacts = filterByTab('contacts');
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() {
this.query = '';
this.$store.dispatch('conversationSearch/clearSearchResults');
contacts: {
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => mappedContacts.value.length,
},
mounted() {
this.$store.dispatch('conversationSearch/clearSearchResults');
conversations: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedConversations.value.length,
},
methods: {
onSearch(q) {
this.selectedTab = 'all';
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' });
}
},
messages: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedMessages.value.length,
},
};
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>
<template>
<div class="flex flex-col w-full bg-n-background">
<div class="flex p-4">
<ButtonV4
:label="$t('GENERAL_SETTINGS.BACK')"
<div class="flex flex-col w-full h-full bg-n-background">
<div class="flex w-full p-4">
<NextButton
:label="t('GENERAL_SETTINGS.BACK')"
icon="i-lucide-chevron-left"
faded
primary
@@ -214,73 +247,119 @@ export default {
@click="onBack"
/>
</div>
<section
class="flex my-0 p-4 relative mx-auto max-w-[45rem] min-h-[20rem] flex-col w-full h-full bg-n-background"
>
<header>
<SearchHeader @search="onSearch" />
<SearchTabs
v-if="query"
:tabs="tabs"
:selected-tab="activeTabIndex"
@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>
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-4xl mx-auto">
<div class="flex flex-col w-full px-4">
<SearchHeader @search="onSearch" />
<SearchTabs
v-if="query"
:tabs="tabs"
:selected-tab="activeTabIndex"
@tab-change="tab => (selectedTab = tab)"
/>
</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
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 class="flex-grow w-full h-full overflow-y-auto">
<div class="w-full max-w-4xl mx-auto px-4 pb-6">
<div v-if="showResultsSection">
<Policy
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
/>
<NextButton
v-if="showViewMore.contacts"
:label="t(`SEARCH.VIEW_MORE`)"
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>
</section>

View File

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

View File

@@ -1,11 +1,19 @@
import { actions } from '../../conversationSearch';
import types from '../../../mutation-types';
import axios from 'axios';
const commit = vi.fn();
const dispatch = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
beforeEach(() => {
commit.mockClear();
dispatch.mockClear();
axios.get.mockClear();
});
describe('#get', () => {
it('sends correct actions if no query param is provided', () => {
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', () => {
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', () => {
const state = { uiFlags: { isFetching: true } };
mutations[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, {
@@ -19,4 +19,99 @@ describe('#mutations', () => {
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_UI_FLAG: 'CONVERSATION_SEARCH_SET_UI_FLAG',
MESSAGE_SEARCH_SET: 'MESSAGE_SEARCH_SET',
CLEAR_SEARCH_RESULTS: 'CLEAR_SEARCH_RESULTS',
MESSAGE_SEARCH_SET_UI_FLAG: 'MESSAGE_SEARCH_SET_UI_FLAG',
FULL_SEARCH_SET_UI_FLAG: 'FULL_SEARCH_SET_UI_FLAG',
SET_CONVERSATION_PARTICIPANTS_UI_FLAG: