mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user