feat: Redesigned search UI (#1845)

This commit is contained in:
Sivin Varghese 2021-03-15 18:38:05 +05:30 committed by GitHub
parent c99c63cd79
commit 36f086c5cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 591 additions and 215 deletions

View file

@ -11,7 +11,7 @@
"TITLE": "Search messages", "TITLE": "Search messages",
"LOADING_MESSAGE": "Crunching data...", "LOADING_MESSAGE": "Crunching data...",
"PLACEHOLDER": "Type any text to search messages", "PLACEHOLDER": "Type any text to search messages",
"NO_MATCHING_RESULTS": "There are no messages matching the search parameters." "NO_MATCHING_RESULTS": "No results found."
}, },
"UNREAD_MESSAGES": "Unread Messages", "UNREAD_MESSAGES": "Unread Messages",
"UNREAD_MESSAGE": "Unread Message", "UNREAD_MESSAGE": "Unread Message",

View file

@ -6,17 +6,7 @@
:active-team="activeTeam" :active-team="activeTeam"
@conversation-load="onConversationLoad" @conversation-load="onConversationLoad"
> >
<button class="search--button" @click="onSearch"> <pop-over-search />
<i class="ion-ios-search-strong search--icon" />
<div class="text-truncate">
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
</div>
</button>
<search
v-if="showSearchModal"
:show="showSearchModal"
:on-close="closeSearch"
/>
</chat-list> </chat-list>
<conversation-box <conversation-box
:inbox-id="inboxId" :inbox-id="inboxId"
@ -29,17 +19,16 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ChatList from '../../../components/ChatList'; import ChatList from '../../../components/ChatList';
import ConversationBox from '../../../components/widgets/conversation/ConversationBox'; import ConversationBox from '../../../components/widgets/conversation/ConversationBox';
import Search from './search/Search.vue'; import PopOverSearch from './search/PopOverSearch';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
export default { export default {
components: { components: {
ChatList, ChatList,
ConversationBox, ConversationBox,
Search, PopOverSearch,
}, },
mixins: [uiSettingsMixin], mixins: [uiSettingsMixin],
props: { props: {
@ -146,31 +135,6 @@ export default {
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.search--button {
align-items: center;
border: 0;
color: var(--s-400);
cursor: pointer;
display: flex;
font-size: var(--font-size-small);
font-weight: 400;
padding: var(--space-normal) var(--space-normal) var(--space-slab);
text-align: left;
line-height: var(--font-size-large);
&:hover {
.search--icon {
color: var(--w-500);
}
}
}
.search--icon {
color: var(--s-600);
font-size: var(--font-size-large);
padding-right: var(--space-small);
}
.conversation-page { .conversation-page {
display: flex; display: flex;
width: 100%; width: 100%;

View file

@ -0,0 +1,228 @@
<template>
<div v-on-clickaway="closeSearch" class="search-wrap">
<div class="search" :class="{ 'is-active': showSearchBox }">
<div class="icon">
<i class="ion-ios-search-strong search--icon" />
</div>
<input
v-model="searchTerm"
class="search--input"
:placeholder="$t('CONVERSATION.SEARCH_MESSAGES')"
@focus="onSearch"
/>
</div>
<div v-if="showSearchBox" class="results-wrap">
<div class="show-results">
<div>
<div class="result-view">
<div class="result">
Search Results
<span v-if="resultsCount" class="message-counter">
({{ resultsCount }})
</span>
</div>
<div v-if="uiFlags.isFetching" class="search--activity-message">
<woot-spinner size="" />
{{ $t('CONVERSATION.SEARCH.LOADING_MESSAGE') }}
</div>
</div>
<div v-if="showSearchResult" class="search-results--container">
<result-item
v-for="conversation in conversations"
:key="conversation.messageId"
:conversation-id="conversation.id"
:user-name="conversation.contact.name"
:timestamp="conversation.created_at"
:messages="conversation.messages"
:search-term="searchTerm"
:inbox-name="conversation.inbox.name"
/>
</div>
<div v-else-if="showEmptyResult" class="search--activity-no-message">
{{ $t('CONVERSATION.SEARCH.NO_MATCHING_RESULTS') }}
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import timeMixin from '../../../../mixins/time';
import ResultItem from './ResultItem';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
components: {
ResultItem,
},
directives: {
focus: {
inserted(el) {
el.focus();
},
},
},
mixins: [timeMixin, messageFormatterMixin, clickaway],
data() {
return {
searchTerm: '',
showSearchBox: false,
};
},
computed: {
...mapGetters({
conversations: 'conversationSearch/getConversations',
uiFlags: 'conversationSearch/getUIFlags',
}),
resultsCount() {
return this.conversations.length;
},
showSearchResult() {
return (
this.searchTerm && this.conversations.length && !this.uiFlags.isFetching
);
},
showEmptyResult() {
return (
this.searchTerm &&
!this.conversations.length &&
!this.uiFlags.isFetching
);
},
},
watch: {
searchTerm(newValue) {
if (this.typingTimer) {
clearTimeout(this.typingTimer);
}
this.typingTimer = setTimeout(() => {
this.hasSearched = true;
this.$store.dispatch('conversationSearch/get', { q: newValue });
}, 1000);
},
},
mounted() {
this.$store.dispatch('conversationSearch/get', { q: '' });
},
methods: {
onSearch() {
this.showSearchBox = true;
},
closeSearch() {
this.showSearchBox = false;
},
},
};
</script>
<style lang="scss" scoped>
.search-wrap {
position: relative;
}
.search {
display: flex;
padding: 0;
border-bottom: 1px solid transparent;
padding: var(--space-one) var(--space-normal) var(--space-smaller)
var(--space-normal);
&:hover {
.search--icon {
color: var(--w-500);
}
}
}
.search--input {
align-items: center;
border: 0;
color: var(--color-body);
cursor: pointer;
width: 100%;
display: flex;
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
text-align: left;
line-height: var(--font-size-large);
}
.search--icon {
color: var(--s-600);
font-size: var(--font-size-large);
padding: 0 var(--space-small) 0 0;
}
.icon {
display: flex;
}
input::placeholder {
color: var(--color-body);
font-size: var(--font-size-small);
}
.results-wrap {
position: absolute;
z-index: 9999;
box-shadow: var(--shadow-large);
background: white;
width: 100%;
max-height: 70vh;
overflow: auto;
}
.show-results {
list-style-type: none;
font-size: var(--font-size-small);
font-weight: var(--font-weight-normal);
}
.result-view {
display: flex;
justify-content: space-between;
}
.result {
padding: var(--space-smaller) var(--space-smaller) var(--space-smaller)
var(--space-normal);
color: var(--s-700);
font-size: var(--font-size-medium);
font-weight: var(--font-weight-bold);
.message-counter {
color: var(--s-500);
font-size: var(--font-size-small);
font-weight: var(--font-weight-bold);
}
}
.search--activity-message {
padding: var(--space-small) var(--space-normal) var(--space-small)
var(--space-zero);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--s-500);
}
.search--activity-no-message {
display: flex;
justify-content: center;
padding: var(--space-one) var(--space-zero) var(--space-two) var(--space-zero);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--s-500);
}
</style>

View file

@ -0,0 +1,191 @@
<template>
<div class="search-result" @click="onClick">
<div class="result-header">
<div class="message">
<i class="ion-ios-chatboxes-outline" />
<div class="conversation">
<div class="name-wrap">
<span class="user-name">{{ userName }}</span>
<div>
<span class="conversation-id"># {{ conversationId }}</span>
</div>
</div>
<span class="inbox-name">{{ inboxName }}</span>
</div>
</div>
<span class="timestamp">{{ readableTime }} </span>
</div>
<search-message-item
v-for="message in messages"
:key="message.created_at"
:user-name="message.sender_name"
:timestamp="message.created_at"
:message-type="message.message_type"
:content="message.content"
:search-term="searchTerm"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import timeMixin from 'dashboard/mixins/time';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import SearchMessageItem from './SearchMessageItem.vue';
export default {
components: { SearchMessageItem },
mixins: [timeMixin, messageFormatterMixin],
props: {
conversationId: {
type: Number,
default: 0,
},
userName: {
type: String,
default: '',
},
inboxName: {
type: String,
default: '',
},
timestamp: {
type: Number,
default: 0,
},
messages: {
type: Array,
default: () => [],
},
searchTerm: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
readableTime() {
if (!this.timestamp) {
return '';
}
return this.dynamicTime(this.timestamp);
},
},
methods: {
onClick() {
const path = conversationUrl({
accountId: this.accountId,
id: this.conversationId,
});
window.location = frontendURL(path);
},
},
};
</script>
<style lang="scss" scoped>
.search-result {
display: block;
align-items: center;
cursor: pointer;
color: var(--color-body);
padding: var(--space-smaller) var(--space-two) 0 var(--space-normal);
&:last-child {
border-bottom: none;
padding-bottom: var(--space-normal);
}
}
.result-header {
display: flex;
justify-content: space-between;
background: var(--color-background);
padding: var(--space-smaller) var(--space-slab);
margin-bottom: var(--space-small);
border-radius: var(--border-radius-medium);
&:hover {
background: var(--w-400);
color: var(--white);
.inbox-name {
color: var(--white);
}
.timestamp {
color: var(--white);
}
.ion-ios-chatboxes-outline {
color: var(--white);
}
.conversation-id {
background: var(--w-50);
color: var(--s-500);
}
}
}
.message {
display: flex;
}
.ion-ios-chatboxes-outline {
align-items: center;
display: flex;
font-size: var(--font-size-large);
color: var(--w-500);
}
.conversation {
align-items: flex-start;
display: flex;
flex-direction: column;
padding: var(--space-smaller) var(--space-one);
}
.name-wrap {
display: flex;
width: 20rem;
.user-name {
font-size: var(--font-size-default);
font-weight: var(--font-weight-bold);
margin-right: var(--space-micro);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.conversation-id {
background: var(--w-400);
border-radius: var(--border-radius-normal);
color: var(--w-50);
align-items: center;
font-size: var(--font-size-mini);
font-weight: var(--font-weight-bold);
padding: 0 var(--space-smaller);
white-space: nowrap;
}
}
.inbox-name {
border-radius: var(--border-radius-normal);
color: var(--s-500);
font-size: var(--font-size-normal);
font-weight: var(--font-weight-medium);
}
.timestamp {
color: var(--s-500);
font-weight: var(--font-weight-medium);
font-size: var(--font-size-mini);
margin-top: var(--space-smaller);
text-align: right;
}
</style>

View file

@ -1,175 +0,0 @@
<template>
<woot-modal
class="message-search--modal"
:show.sync="show"
:on-close="onClose"
>
<woot-modal-header :header-title="$t('CONVERSATION.SEARCH.TITLE')" />
<div class="search--container">
<input
v-model="searchTerm"
v-focus
:placeholder="$t('CONVERSATION.SEARCH.PLACEHOLDER')"
type="text"
/>
<div v-if="uiFlags.isFetching" class="search--activity-message">
<woot-spinner size="" />
{{ $t('CONVERSATION.SEARCH.LOADING_MESSAGE') }}
</div>
<div
v-if="searchTerm && conversations.length && !uiFlags.isFetching"
class="search-results--container"
>
<div v-for="conversation in conversations" :key="conversation.id">
<button
v-for="message in conversation.messages"
:key="message.id"
class="search--messages"
@click="() => onClick(conversation)"
>
<div class="search--messages__metadata">
<span>#{{ conversation.id }}</span>
<span>{{ dynamicTime(message.created_at) }}</span>
</div>
<div v-html="prepareContent(message.content)" />
</button>
</div>
</div>
<div
v-else-if="
searchTerm &&
!conversations.length &&
!uiFlags.isFetching &&
hasSearched
"
class="search--activity-message"
>
{{ $t('CONVERSATION.SEARCH.NO_MATCHING_RESULTS') }}
</div>
</div>
</woot-modal>
</template>
<script>
import { mapGetters } from 'vuex';
import { frontendURL, conversationUrl } from '../../../../helper/URLHelper';
import timeMixin from '../../../../mixins/time';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
directives: {
focus: {
inserted(el) {
el.focus();
},
},
},
mixins: [timeMixin, messageFormatterMixin],
props: {
show: {
type: Boolean,
default: false,
},
onClose: {
type: Function,
default: () => {},
},
},
data() {
return {
searchTerm: '',
hasSearched: false,
};
},
computed: {
...mapGetters({
conversations: 'conversationSearch/getConversations',
uiFlags: 'conversationSearch/getUIFlags',
accountId: 'getCurrentAccountId',
}),
},
watch: {
searchTerm(newValue) {
if (this.typingTimer) {
clearTimeout(this.typingTimer);
}
this.typingTimer = setTimeout(() => {
this.hasSearched = true;
this.$store.dispatch('conversationSearch/get', { q: newValue });
}, 1000);
},
},
mounted() {
this.$store.dispatch('conversationSearch/get', { q: '' });
},
methods: {
prepareContent(content = '') {
const plainTextContent = this.getPlainText(content);
return plainTextContent.replace(
new RegExp(`(${this.searchTerm})`, 'ig'),
'<span class="searchkey--highlight">$1</span>'
);
},
onClick(conversation) {
const path = conversationUrl({
accountId: this.accountId,
id: conversation.id,
});
window.location = frontendURL(path);
},
},
};
</script>
<style lang="scss">
.search--container {
font-size: var(--font-size-default);
padding: var(--space-normal) var(--space-large);
}
.search-results--container {
max-height: 300px;
overflow: scroll;
}
.searchkey--highlight {
background: var(--w-500);
color: var(--white);
}
.search--activity-message {
color: var(--s-800);
text-align: center;
}
.search--messages {
border-bottom: 1px solid var(--b-100);
color: var(--color-body);
cursor: pointer;
font-size: var(--font-size-small);
line-height: 1.5;
padding: var(--space-normal);
text-align: left;
width: 100%;
&:hover {
background: var(--w-50);
}
}
.message-search--modal .modal-container {
width: 800px;
min-height: 460px;
}
.search--messages__metadata {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-small);
color: var(--s-500);
}
</style>

View file

@ -0,0 +1,155 @@
<template>
<div class="message-item">
<div class="search-message">
<div class="user-wrap">
<div class="name-wrap">
<span class="user-name">{{ userName }}</span>
<div>
<i v-if="isOutgoingMessage" class="ion-headphone" />
</div>
</div>
<span class="timestamp">{{ readableTime }} </span>
</div>
<p class="message-content" v-html="prepareContent(content)"></p>
</div>
</div>
</template>
<script>
import { MESSAGE_TYPE } from 'shared/constants/messages';
import timeMixin from 'dashboard/mixins/time';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
mixins: [timeMixin, messageFormatterMixin],
props: {
userName: {
type: String,
default: '',
},
timestamp: {
type: Number,
default: 0,
},
messageType: {
type: Number,
default: 0,
},
content: {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
},
computed: {
isOutgoingMessage() {
return this.messageType === MESSAGE_TYPE.OUTGOING;
},
readableTime() {
if (!this.timestamp) {
return '';
}
return this.dynamicTime(this.timestamp);
},
},
methods: {
prepareContent(content = '') {
const plainTextContent = this.getPlainText(content);
return plainTextContent.replace(
new RegExp(`(${this.searchTerm})`, 'ig'),
'<span class="searchkey--highlight">$1</span>'
);
},
},
};
</script>
<style lang="scss" scoped>
.message-item {
background: var(--color-background-light);
border-radius: var(--border-radius-medium);
color: var(--color-body);
margin-bottom: var(--space-small);
margin-left: var(--space-one);
padding: 0 var(--space-small);
&:hover {
background: var(--w-400);
color: var(--white);
.message-content::v-deep .searchkey--highlight {
color: var(--white);
text-decoration: underline;
}
.ion-headphone {
color: var(--white);
}
}
&:last-child {
.search-message {
border-bottom: none;
}
}
}
.search-message {
padding: var(--space-smaller) var(--space-smaller);
&:hover {
color: var(--white);
}
}
.user-wrap {
display: flex;
justify-content: space-between;
}
.name-wrap {
display: flex;
width: 22rem;
.user-name {
font-size: var(--font-size-small);
font-weight: var(--font-weight-bold);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.ion-headphone {
color: var(--w-500);
font-size: var(--font-size-mini);
padding: var(--space-micro);
padding-right: var(--space-smaller);
}
.timestamp {
font-size: var(--font-size-mini);
top: var(--space-micro);
position: relative;
text-align: right;
}
p {
max-width: 100%;
}
.message-content {
font-size: var(--font-size-small);
margin-bottom: var(--space-micro);
padding: 0;
line-height: 1.35;
overflow-wrap: break-word;
}
.message-content::v-deep .searchkey--highlight {
color: var(--w-600);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-small);
padding: (var(--space-zero) var(--space-zero));
}
</style>

View file

@ -6,9 +6,22 @@ end
json.payload do json.payload do
json.array! @conversations do |conversation| json.array! @conversations do |conversation|
json.id conversation.display_id json.id conversation.display_id
json.created_at conversation.created_at.to_i
json.contact do
json.id conversation.contact.id
json.name conversation.contact.name
end
json.inbox do
json.id conversation.inbox.id
json.name conversation.inbox.name
json.channel_type conversation.inbox.channel_type
end
json.messages do json.messages do
json.array! conversation.messages do |message| json.array! conversation.messages do |message|
json.content message.content json.content message.content
json.id message.id
json.sender_name message.sender.name if message.sender
json.message_type message.message_type_before_type_cast
json.created_at message.created_at.to_i json.created_at message.created_at.to_i
end end
end end