feat: Updated the search result fly-out menu design (#8203)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Sivin Varghese
2023-10-26 07:46:49 +05:30
committed by GitHub
parent b6831d464e
commit 3e54d3654b
13 changed files with 67 additions and 592 deletions

View File

@@ -71,18 +71,11 @@ export default {
},
prepareContent(content = '') {
const escapedText = this.escapeHtml(content);
const plainTextContent = this.getPlainText(escapedText);
const escapedSearchTerm = this.escapeRegExp(this.searchTerm);
return plainTextContent
.replace(
new RegExp(`(${escapedSearchTerm})`, 'ig'),
'<span class="searchkey--highlight">$1</span>'
)
.replace(/\s{2,}|\n|\r/g, ' ');
},
// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return this.highlightContent(
escapedText,
this.searchTerm,
'searchkey--highlight'
);
},
},
};

View File

@@ -1,80 +0,0 @@
<template>
<div class="relative">
<div
class="flex px-4 pb-1 flex-row gap-1 pt-2.5 border-b border-transparent"
>
<woot-sidemenu-icon
size="tiny"
class="relative top-0 ltr:-ml-1.5 rtl:-mr-1.5"
/>
<router-link
:to="searchUrl"
class="search-link flex-1 items-center gap-1 text-left h-6 rtl:mr-3 rtl:text-right rounded-md px-2 py-0 bg-slate-25 dark:bg-slate-800 inline-flex"
>
<div class="flex">
<fluent-icon
icon="search"
class="search--icon text-slate-800 dark:text-slate-200"
size="16"
/>
</div>
<p
class="search--label mb-0 overflow-hidden whitespace-nowrap text-ellipsis text-sm text-slate-800 dark:text-slate-200"
>
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
</p>
</router-link>
<switch-layout
:is-on-expanded-layout="isOnExpandedLayout"
@toggle="$emit('toggle-conversation-layout')"
/>
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import timeMixin from '../../../../mixins/time';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import SwitchLayout from './SwitchLayout.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
components: {
SwitchLayout,
},
directives: {
focus: {
inserted(el) {
el.focus();
},
},
},
mixins: [timeMixin, messageFormatterMixin, clickaway],
props: {
isOnExpandedLayout: {
type: Boolean,
required: true,
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
}),
searchUrl() {
return frontendURL(`accounts/${this.accountId}/search`);
},
},
};
</script>
<style lang="scss" scoped>
.search-link {
&:hover {
.search--icon,
.search--label {
@apply hover:text-woot-500 dark:hover:text-woot-500;
}
}
}
</style>

View File

@@ -1,41 +0,0 @@
import ResultItem from './ResultItem';
export default {
title: 'Components/Search/Result Items',
component: ResultItem,
argTypes: {
conversationId: {
defaultValue: '1',
control: {
type: 'number',
},
},
userName: {
defaultValue: 'John davies',
control: {
type: 'text',
},
},
inboxName: {
defaultValue: 'Support',
control: {
type: 'text',
},
},
timestamp: {
defaultValue: '1618046084',
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ResultItem },
template: '<result-item v-bind="$props"></result-item>',
});
export const ResultItems = Template.bind({});
ResultItems.args = {};

View File

@@ -1,185 +0,0 @@
<template>
<div class="search-result" @click="onClick">
<div class="result-header">
<div class="conversation--block">
<fluent-icon icon="chat" class="icon--conversation-search-item" />
<div class="conversation">
<div class="user-wrap">
<div class="name-wrap">
<span class="sub-block-title">{{ userName }}</span>
</div>
<woot-label
:title="conversationsId"
:small="true"
color-scheme="secondary"
/>
</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',
}),
conversationsId() {
return `# ${this.conversationId}`;
},
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);
}
.icon--conversation-search-item {
color: var(--white);
}
}
}
.conversation--block {
align-items: center;
display: flex;
}
.icon--conversation-search-item {
align-items: center;
display: flex;
color: var(--w-500);
}
.conversation {
align-items: flex-start;
display: flex;
flex-direction: column;
padding: var(--space-smaller) var(--space-one);
}
.user-wrap {
display: flex;
.name-wrap {
max-width: 12.5rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
.sub-block-title {
font-weight: var(--font-weight-bold);
margin-right: var(--space-micro);
}
}
}
.inbox-name {
border-radius: var(--border-radius-normal);
color: var(--s-500);
font-size: var(--font-size-mini);
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,47 +0,0 @@
import SearchMessage from './SearchMessageItem';
export default {
title: 'Components/Search/Messages',
component: SearchMessage,
argTypes: {
userName: {
defaultValue: 'John davies',
control: {
type: 'text',
},
},
timestamp: {
defaultValue: '1618046084',
control: {
type: 'number',
},
},
messageType: {
control: {
type: 'number',
},
},
content: {
defaultValue:
'some designers and developers around the web know this and have put together a bunch of text generators',
control: {
type: 'text',
},
},
searchTerm: {
defaultValue: 'developers',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { SearchMessage },
template: '<search-message v-bind="$props"></-item>',
});
export const Messages = Template.bind({});
Messages.args = {};

View File

@@ -1,163 +0,0 @@
<template>
<div class="message-item">
<div class="search-message">
<div class="user-wrap">
<div class="name-wrap">
<span class="text-block-title">{{ userName }}</span>
<div>
<fluent-icon
v-if="isOutgoingMessage"
icon="arrow-reply"
class="icon-outgoing"
/>
</div>
</div>
<span class="timestamp">{{ readableTime }} </span>
</div>
<p v-dompurify-html="prepareContent(content)" class="message-content" />
</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);
const escapedSearchTerm = this.escapeRegExp(this.searchTerm);
return plainTextContent.replace(
new RegExp(`(${escapedSearchTerm})`, 'ig'),
'<span class="searchkey--highlight">$1</span>'
);
},
// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
},
};
</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;
}
.icon-outgoing {
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;
max-width: 13.75rem;
.text-block-title {
font-weight: var(--font-weight-bold);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.icon-outgoing {
color: var(--w-500);
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);
margin-top: 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

@@ -1,36 +0,0 @@
<template>
<woot-button
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
icon="arrow-right-import"
size="tiny"
variant="smooth"
color-scheme="secondary"
class="layout-switch__container"
:class="{ expanded: isOnExpandedLayout }"
@click="toggle"
/>
</template>
<script>
export default {
props: {
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
methods: {
toggle() {
this.$emit('toggle');
},
},
};
</script>
<style lang="scss" soped>
.layout-switch__container {
&.expanded .icon {
transform: rotate(180deg);
}
}
</style>

View File

@@ -9,7 +9,7 @@
"
:show-new-button="false"
/>
<div>
<div class="overflow-auto max-h-[96%]">
<setting-intro-banner :header-title="portalName">
<woot-tabs
:index="activeTabIndex"
@@ -24,9 +24,9 @@
/>
</woot-tabs>
</setting-intro-banner>
</div>
<div class="overflow-auto p-4 max-w-full my-auto flex flex-wrap">
<router-view />
<div class="p-4 max-w-full my-auto flex flex-wrap">
<router-view />
</div>
</div>
</div>
</template>

View File

@@ -1,7 +1,12 @@
import Rails from '@rails/ujs';
import Turbolinks from 'turbolinks';
import '../portal/application.scss';
import Vue from 'vue';
import { InitializationHelpers } from '../portal/portalHelpers';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Rails.start();
Turbolinks.start();

View File

@@ -1,5 +1,5 @@
<template>
<div v-on-clickaway="closeSearch" class="max-w-2xl w-full relative my-4">
<div v-on-clickaway="closeSearch" class="max-w-6xl w-full relative my-4">
<public-search-input
v-model="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
@@ -7,12 +7,13 @@
/>
<div
v-if="shouldShowSearchBox"
class="absolute top-16 w-full"
class="absolute top-14 w-full"
@mouseover="openSearch"
>
<search-suggestions
:items="searchResults"
:is-loading="isLoading"
:search-term="searchTerm"
:empty-placeholder="searchTranslations.emptyPlaceholder"
:results-title="searchTranslations.resultsTitle"
:loading-placeholder="searchTranslations.loadingPlaceholder"

View File

@@ -1,6 +1,6 @@
<template>
<div
class="w-full flex items-center rounded-md border-solid border-2 h-16 bg-white dark:bg-slate-900 px-4 py-2 text-slate-600 dark:text-slate-200"
class="w-full flex items-center rounded-lg border-solid border h-12 bg-white dark:bg-slate-900 px-5 py-2 text-slate-600 dark:text-slate-200"
:class="{
'shadow border-woot-100 dark:border-woot-700': isFocused,
'border-slate-50 dark:border-slate-800 shadow-sm': !isFocused,

View File

@@ -1,6 +1,6 @@
<template>
<div
class="shadow-md bg-white dark:bg-slate-900 mt-2 max-h-72 scroll-py-2 p-4 rounded overflow-y-auto text-sm text-slate-700 dark:text-slate-100"
class="shadow-xl hover:shadow-lg bg-white dark:bg-slate-900 mt-2 max-h-96 scroll-py-2 p-5 overflow-y-auto text-sm text-slate-700 dark:text-slate-100 border border-solid border-slate-50 dark:border-slate-800 rounded-lg"
>
<div
v-if="isLoading"
@@ -8,32 +8,34 @@
>
{{ loadingPlaceholder }}
</div>
<h3
v-if="shouldShowResults"
class="font-medium text-sm text-slate-400 dark:text-slate-700"
>
{{ resultsTitle }}
</h3>
<ul
v-if="shouldShowResults"
class="bg-white dark:bg-slate-900 mt-2 max-h-72 scroll-py-2 overflow-y-auto text-sm text-slate-700 dark:text-slate-100"
class="bg-white dark:bg-slate-900 gap-4 flex flex-col text-sm text-slate-700 dark:text-slate-100"
role="listbox"
>
<li
v-for="(article, index) in items"
:id="article.id"
:key="article.id"
class="group flex cursor-default select-none items-center rounded-md p-2 mb-1"
:class="{ 'bg-slate-25 dark:bg-slate-800': index === selectedIndex }"
class="group flex border border-solid hover:bg-slate-25 dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800 rounded-lg cursor-pointer select-none items-center p-4"
:class="isSearchItemActive(index)"
role="option"
tabindex="-1"
@mouseover="onHover(index)"
@mouse-enter="onHover(index)"
@mouse-leave="onHover(-1)"
>
<a
class="flex flex-col gap-1 overflow-y-hidden"
:href="generateArticleUrl(article)"
class="flex-auto truncate text-base font-medium leading-6 w-full hover:underline"
>
{{ article.title }}
<span
v-dompurify-html="prepareContent(article.title)"
class="flex-auto truncate text-base font-semibold leading-6 w-full overflow-hidden text-ellipsis whitespace-nowrap"
/>
<div
v-dompurify-html="prepareContent(article.content)"
class="line-clamp-2 text-ellipsis text-slate-600 dark:text-slate-300 text-sm"
/>
</a>
</li>
</ul>
@@ -49,9 +51,10 @@
<script>
import mentionSelectionKeyboardMixin from 'dashboard/components/widgets/mentions/mentionSelectionKeyboardMixin.js';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
mixins: [mentionSelectionKeyboardMixin],
mixins: [mentionSelectionKeyboardMixin, messageFormatterMixin],
props: {
items: {
type: Array,
@@ -77,10 +80,14 @@ export default {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
},
data() {
return {
selectedIndex: 0,
selectedIndex: -1,
};
},
@@ -94,18 +101,24 @@ export default {
},
methods: {
isSearchItemActive(index) {
return index === this.selectedIndex
? 'bg-slate-25 dark:bg-slate-800'
: 'bg-white dark:bg-slate-900';
},
generateArticleUrl(article) {
return `/hc/${article.portal.slug}/articles/${article.slug}`;
},
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
this.$el.scrollTop = 40 * this.selectedIndex;
this.$el.scrollTop = 102 * this.selectedIndex;
},
onHover(index) {
this.selectedIndex = index;
},
onSelect() {
window.location = this.generateArticleUrl(this.items[this.selectedIndex]);
prepareContent(content) {
return this.highlightContent(
content,
this.searchTerm,
'bg-slate-100 dark:bg-slate-700 font-semibold text-slate-600 dark:text-slate-200'
);
},
},
};

View File

@@ -21,5 +21,20 @@ export default {
return `${description.slice(0, 97)}...`;
},
highlightContent(content = '', searchTerm = '', highlightClass = '') {
const plainTextContent = this.getPlainText(content);
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
const escapedSearchTerm = searchTerm.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
return plainTextContent.replace(
new RegExp(`(${escapedSearchTerm})`, 'ig'),
`<span class="${highlightClass}">$1</span>`
);
},
},
};