mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-03 20:48:07 +00:00 
			
		
		
		
	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:
		@@ -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,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,9 @@
 | 
			
		||||
<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,
 | 
			
		||||
  },
 | 
			
		||||
  props: {
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  author: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    default: '',
 | 
			
		||||
@@ -19,55 +16,44 @@ export default {
 | 
			
		||||
    type: String,
 | 
			
		||||
    default: '',
 | 
			
		||||
  },
 | 
			
		||||
  },
 | 
			
		||||
  setup() {
 | 
			
		||||
    const { formatMessage, highlightContent } = useMessageFormatter();
 | 
			
		||||
    return {
 | 
			
		||||
      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);
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    setOverflow() {
 | 
			
		||||
      const wrap = this.$refs.messageContainer;
 | 
			
		||||
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');
 | 
			
		||||
        this.isOverflowing = message.offsetHeight > 150;
 | 
			
		||||
    isOverflowing.value = message.offsetHeight > 150;
 | 
			
		||||
  }
 | 
			
		||||
    },
 | 
			
		||||
    escapeHtml(html) {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const 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(
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const prepareContent = (content = '') => {
 | 
			
		||||
  const escapedText = escapeHtml(content);
 | 
			
		||||
  return highlightContent(
 | 
			
		||||
    escapedText,
 | 
			
		||||
        this.searchTerm,
 | 
			
		||||
    props.searchTerm,
 | 
			
		||||
    'searchkey--highlight'
 | 
			
		||||
  );
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  watch(() => {
 | 
			
		||||
    return messageContainer.value;
 | 
			
		||||
  }, setOverflow);
 | 
			
		||||
 | 
			
		||||
  nextTick(setOverflow);
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
 
 | 
			
		||||
@@ -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]"
 | 
			
		||||
      >
 | 
			
		||||
        <woot-button
 | 
			
		||||
        v-if="shrink"
 | 
			
		||||
          size="tiny"
 | 
			
		||||
          variant="smooth"
 | 
			
		||||
          color-scheme="primary"
 | 
			
		||||
          @click.prevent="$emit('expand')"
 | 
			
		||||
        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"
 | 
			
		||||
      >
 | 
			
		||||
          {{ $t('SEARCH.READ_MORE') }}
 | 
			
		||||
        </woot-button>
 | 
			
		||||
        <NextButton
 | 
			
		||||
          :label="$t('SEARCH.READ_MORE')"
 | 
			
		||||
          icon="i-lucide-chevrons-down"
 | 
			
		||||
          blue
 | 
			
		||||
          xs
 | 
			
		||||
          faded
 | 
			
		||||
          class="backdrop-filter backdrop-blur-[2px]"
 | 
			
		||||
          @click.prevent="$emit('expand')"
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -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) {
 | 
			
		||||
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();
 | 
			
		||||
        this.$refs.searchInput.focus();
 | 
			
		||||
      } else if (
 | 
			
		||||
        e.key === 'Escape' &&
 | 
			
		||||
        document.activeElement.tagName === 'INPUT'
 | 
			
		||||
      ) {
 | 
			
		||||
    searchInput.value.focus();
 | 
			
		||||
  } else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
        this.$refs.searchInput.blur();
 | 
			
		||||
    searchInput.value.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>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
@@ -76,7 +75,7 @@ export default {
 | 
			
		||||
      :value="searchQuery"
 | 
			
		||||
      @focus="onFocus"
 | 
			
		||||
      @blur="onBlur"
 | 
			
		||||
      @input="debounceSearch"
 | 
			
		||||
      @input="onInput"
 | 
			
		||||
    />
 | 
			
		||||
    <woot-label
 | 
			
		||||
      :title="$t('SEARCH.PLACEHOLDER_KEYBINDING')"
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,10 @@
 | 
			
		||||
<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,
 | 
			
		||||
  },
 | 
			
		||||
  props: {
 | 
			
		||||
defineProps({
 | 
			
		||||
  contacts: {
 | 
			
		||||
    type: Array,
 | 
			
		||||
    default: () => [],
 | 
			
		||||
@@ -26,13 +21,9 @@ export default {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    default: true,
 | 
			
		||||
  },
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    ...mapGetters({
 | 
			
		||||
      accountId: 'getCurrentAccountId',
 | 
			
		||||
    }),
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,12 @@
 | 
			
		||||
<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,
 | 
			
		||||
  },
 | 
			
		||||
  props: {
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  id: {
 | 
			
		||||
    type: Number,
 | 
			
		||||
    default: 0,
 | 
			
		||||
@@ -36,37 +35,35 @@ export default {
 | 
			
		||||
    type: Number,
 | 
			
		||||
    default: 0,
 | 
			
		||||
  },
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    navigateTo() {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const navigateTo = computed(() => {
 | 
			
		||||
  const params = {};
 | 
			
		||||
      if (this.messageId) {
 | 
			
		||||
        params.messageId = this.messageId;
 | 
			
		||||
  if (props.messageId) {
 | 
			
		||||
    params.messageId = props.messageId;
 | 
			
		||||
  }
 | 
			
		||||
  return frontendURL(
 | 
			
		||||
        `accounts/${this.accountId}/conversations/${this.id}`,
 | 
			
		||||
    `accounts/${props.accountId}/conversations/${props.id}`,
 | 
			
		||||
    params
 | 
			
		||||
  );
 | 
			
		||||
    },
 | 
			
		||||
    createdAtTime() {
 | 
			
		||||
      return dynamicTime(this.createdAt);
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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"
 | 
			
		||||
          class="text-xs font-normal min-w-0 truncate text-n-slate-11 dark:text-n-slate-11"
 | 
			
		||||
        >
 | 
			
		||||
          {{ createdAtTime }}
 | 
			
		||||
        </span>
 | 
			
		||||
      </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="flex gap-2">
 | 
			
		||||
        <h5
 | 
			
		||||
          v-if="name"
 | 
			
		||||
          class="m-0 text-sm text-n-slate-12 dark:text-n-slate-12"
 | 
			
		||||
        >
 | 
			
		||||
          <span
 | 
			
		||||
            class="text-xs font-normal text-n-slate-11 dark:text-n-slate-11"
 | 
			
		||||
          class="m-0 text-sm min-w-0 truncate text-n-slate-12 dark:text-n-slate-12"
 | 
			
		||||
        >
 | 
			
		||||
          <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"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,9 @@
 | 
			
		||||
<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,
 | 
			
		||||
  },
 | 
			
		||||
  props: {
 | 
			
		||||
defineProps({
 | 
			
		||||
  conversations: {
 | 
			
		||||
    type: Array,
 | 
			
		||||
    default: () => [],
 | 
			
		||||
@@ -25,13 +20,9 @@ export default {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    default: true,
 | 
			
		||||
  },
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    ...mapGetters({
 | 
			
		||||
      accountId: 'getCurrentAccountId',
 | 
			
		||||
    }),
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,12 @@
 | 
			
		||||
<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,
 | 
			
		||||
  },
 | 
			
		||||
  props: {
 | 
			
		||||
defineProps({
 | 
			
		||||
  messages: {
 | 
			
		||||
    type: Array,
 | 
			
		||||
    default: () => [],
 | 
			
		||||
@@ -27,19 +23,15 @@ export default {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    default: true,
 | 
			
		||||
  },
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    ...mapGetters({
 | 
			
		||||
      accountId: 'getCurrentAccountId',
 | 
			
		||||
    }),
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    getName(message) {
 | 
			
		||||
});
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
 | 
			
		||||
const accountId = useMapGetter('getCurrentAccountId');
 | 
			
		||||
 | 
			
		||||
const getName = message => {
 | 
			
		||||
  return message && message.sender && message.sender.name
 | 
			
		||||
    ? message.sender.name
 | 
			
		||||
        : this.$t('SEARCH.BOT_LABEL');
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
    : 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"
 | 
			
		||||
 
 | 
			
		||||
@@ -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">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, watch } from 'vue';
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  tabs: {
 | 
			
		||||
    type: Array,
 | 
			
		||||
    default: () => [],
 | 
			
		||||
@@ -9,31 +10,29 @@ export default {
 | 
			
		||||
    type: Number,
 | 
			
		||||
    default: 0,
 | 
			
		||||
  },
 | 
			
		||||
  },
 | 
			
		||||
  emits: ['tabChange'],
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      activeTab: 0,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    selectedTab(value, oldValue) {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['tabChange']);
 | 
			
		||||
 | 
			
		||||
const activeTab = ref(props.selectedTab);
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  () => props.selectedTab,
 | 
			
		||||
  (value, oldValue) => {
 | 
			
		||||
    if (value !== oldValue) {
 | 
			
		||||
        this.activeTab = this.selectedTab;
 | 
			
		||||
      activeTab.value = props.selectedTab;
 | 
			
		||||
    }
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    onTabChange(index) {
 | 
			
		||||
      this.activeTab = index;
 | 
			
		||||
      this.$emit('tabChange', this.tabs[index].key);
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
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');
 | 
			
		||||
 | 
			
		||||
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')
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
      return filteredCounts.reduce((total, count) => total + count(), 0);
 | 
			
		||||
    },
 | 
			
		||||
    tabs() {
 | 
			
		||||
      const allTabsConfig = {
 | 
			
		||||
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: {
 | 
			
		||||
          key: 'all',
 | 
			
		||||
          name: this.$t('SEARCH.TABS.ALL'),
 | 
			
		||||
          count: this.totalSearchResultsCount,
 | 
			
		||||
          permissions: [
 | 
			
		||||
            this.contactPermissions,
 | 
			
		||||
            ...this.rolePermissions,
 | 
			
		||||
            ...this.conversationPermissions,
 | 
			
		||||
          ],
 | 
			
		||||
    permissions: [CONTACT_PERMISSIONS, ...ROLES, ...CONVERSATION_PERMISSIONS],
 | 
			
		||||
    count: () => null, // No count for all tab
 | 
			
		||||
  },
 | 
			
		||||
  contacts: {
 | 
			
		||||
          key: 'contacts',
 | 
			
		||||
          name: this.$t('SEARCH.TABS.CONTACTS'),
 | 
			
		||||
          count: this.contacts.length,
 | 
			
		||||
          permissions: [...this.rolePermissions, this.contactPermissions],
 | 
			
		||||
    permissions: [...ROLES, CONTACT_PERMISSIONS],
 | 
			
		||||
    count: () => mappedContacts.value.length,
 | 
			
		||||
  },
 | 
			
		||||
  conversations: {
 | 
			
		||||
          key: 'conversations',
 | 
			
		||||
          name: this.$t('SEARCH.TABS.CONVERSATIONS'),
 | 
			
		||||
          count: this.conversations.length,
 | 
			
		||||
          permissions: [
 | 
			
		||||
            ...this.rolePermissions,
 | 
			
		||||
            ...this.conversationPermissions,
 | 
			
		||||
          ],
 | 
			
		||||
    permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
 | 
			
		||||
    count: () => mappedConversations.value.length,
 | 
			
		||||
  },
 | 
			
		||||
  messages: {
 | 
			
		||||
          key: 'messages',
 | 
			
		||||
          name: this.$t('SEARCH.TABS.MESSAGES'),
 | 
			
		||||
          count: this.messages.length,
 | 
			
		||||
          permissions: [
 | 
			
		||||
            ...this.rolePermissions,
 | 
			
		||||
            ...this.conversationPermissions,
 | 
			
		||||
          ],
 | 
			
		||||
    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(
 | 
			
		||||
        allTabsConfig,
 | 
			
		||||
        this.userPermissions,
 | 
			
		||||
    configs,
 | 
			
		||||
    userPermissions.value,
 | 
			
		||||
    item => item.permissions
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const totalSearchResultsCount = computed(() => {
 | 
			
		||||
  const permissionCounts = {
 | 
			
		||||
    contacts: {
 | 
			
		||||
      permissions: [...ROLES, CONTACT_PERMISSIONS],
 | 
			
		||||
      count: () => contacts.value.length,
 | 
			
		||||
    },
 | 
			
		||||
    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';
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  unmounted() {
 | 
			
		||||
    this.query = '';
 | 
			
		||||
    this.$store.dispatch('conversationSearch/clearSearchResults');
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    this.$store.dispatch('conversationSearch/clearSearchResults');
 | 
			
		||||
  },
 | 
			
		||||
  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' });
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    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,10 +247,9 @@ 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>
 | 
			
		||||
    <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"
 | 
			
		||||
@@ -225,10 +257,15 @@ export default {
 | 
			
		||||
            :selected-tab="activeTabIndex"
 | 
			
		||||
            @tab-change="tab => (selectedTab = tab)"
 | 
			
		||||
          />
 | 
			
		||||
      </header>
 | 
			
		||||
      <div class="flex-grow h-full px-2 py-0 overflow-y-auto">
 | 
			
		||||
        </div>
 | 
			
		||||
      </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="[...rolePermissions, contactPermissions]">
 | 
			
		||||
            <Policy
 | 
			
		||||
              :permissions="[...ROLES, CONTACT_PERMISSIONS]"
 | 
			
		||||
              class="flex flex-col justify-center"
 | 
			
		||||
            >
 | 
			
		||||
              <SearchResultContactsList
 | 
			
		||||
                v-if="filterContacts"
 | 
			
		||||
                :is-fetching="uiFlags.contact.isFetching"
 | 
			
		||||
@@ -236,10 +273,20 @@ export default {
 | 
			
		||||
                :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="[...rolePermissions, ...conversationPermissions]"
 | 
			
		||||
              :permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
 | 
			
		||||
              class="flex flex-col justify-center"
 | 
			
		||||
            >
 | 
			
		||||
              <SearchResultMessagesList
 | 
			
		||||
                v-if="filterMessages"
 | 
			
		||||
@@ -248,10 +295,20 @@ export default {
 | 
			
		||||
                :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="[...rolePermissions, ...conversationPermissions]"
 | 
			
		||||
              :permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
 | 
			
		||||
              class="flex flex-col justify-center"
 | 
			
		||||
            >
 | 
			
		||||
              <SearchResultConversationsList
 | 
			
		||||
                v-if="filterConversations"
 | 
			
		||||
@@ -260,7 +317,28 @@ export default {
 | 
			
		||||
                :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"
 | 
			
		||||
@@ -268,21 +346,22 @@ export default {
 | 
			
		||||
          >
 | 
			
		||||
            <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 }) }}
 | 
			
		||||
              {{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div
 | 
			
		||||
          v-else
 | 
			
		||||
            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') }}
 | 
			
		||||
              {{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
 | 
			
		||||
            </p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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 },
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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([]);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,8 @@ class SearchService
 | 
			
		||||
                                    .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}%")
 | 
			
		||||
                                    .order('conversations.created_at DESC')
 | 
			
		||||
                                    .limit(10)
 | 
			
		||||
                                    .page(params[:page])
 | 
			
		||||
                                    .per(15)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def filter_messages
 | 
			
		||||
@@ -38,13 +39,14 @@ class SearchService
 | 
			
		||||
                               .where('messages.content ILIKE :search', search: "%#{search_query}%")
 | 
			
		||||
                               .where('created_at >= ?', 3.months.ago)
 | 
			
		||||
                               .reorder('created_at DESC')
 | 
			
		||||
                               .limit(10)
 | 
			
		||||
                               .page(params[:page])
 | 
			
		||||
                               .per(15)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def filter_contacts
 | 
			
		||||
    @contacts = current_account.contacts.where(
 | 
			
		||||
      "name ILIKE :search OR email ILIKE :search OR phone_number
 | 
			
		||||
      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
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user