feat: Lets users insert connected portal article into replies [CW-2282] (#8117)

- Lets users insert connected portal articles into replies

https://linear.app/chatwoot/issue/CW-2282/list-all-the-top-articles-from-the-connected-help-center
https://linear.app/chatwoot/issue/CW-1453/container-view-for-showing-search-input-and-result-items
This commit is contained in:
Nithin David Thomas
2023-11-04 15:27:25 +05:30
committed by GitHub
parent b4d20689b7
commit 39d0748a5b
14 changed files with 327 additions and 54 deletions

View File

@@ -1,17 +1,18 @@
<template>
<div
class="flex flex-col gap-1 bg-white dark:bg-slate-900 hover:bg-slate-25 hover:dark:bg-slate-800 rounded-md py-1 px-2 w-full group"
<button
class="flex flex-col gap-1 bg-white dark:bg-slate-900 hover:bg-slate-25 hover:dark:bg-slate-800 rounded-md py-1 px-2 w-full group border border-transparent border-solid focus:outline-none focus:bg-slate-25 focus:border-slate-500 dark:focus:border-slate-400 dark:focus:bg-slate-800 cursor-pointer"
@click="handlePreview"
>
<button @click="handlePreview">
<h4
class="text-block-title text-left mb-0 text-slate-900 dark:text-slate-25 px-1 -mx-1 rounded-sm hover:underline cursor-pointer width-auto"
>
{{ title }}
</h4>
</button>
<h4
class="text-block-title text-left mb-0 text-slate-900 dark:text-slate-25 px-1 -mx-1 rounded-sm width-auto hover:underline group-hover:underline"
>
{{ title }}
</h4>
<div class="flex content-between items-center gap-0.5 w-full">
<p class="text-sm text-slate-600 dark:text-slate-300 mb-0 w-full">
<p
class="text-sm text-left text-slate-600 dark:text-slate-300 mb-0 w-full"
>
{{ locale }}
{{ ` / ` }}
{{ category || $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.UNCATEGORIZED') }}
@@ -26,26 +27,6 @@
class="invisible group-hover:visible"
@click="handleCopy"
/>
<a
:href="url"
class="button hollow button--only-icon tiny secondary invisible group-hover:visible"
rel="noopener noreferrer nofollow"
target="_blank"
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.OPEN_LINK')"
>
<fluent-icon size="12" icon="arrow-up-right" />
<span class="show-for-sr">{{ url }}</span>
</a>
<woot-button
variant="hollow"
color-scheme="secondary"
size="tiny"
icon="preview-link"
class="invisible group-hover:visible"
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.PREVIEW_LINK')"
@click="handlePreview"
/>
<woot-button
class="insert-button"
variant="smooth"
@@ -57,14 +38,16 @@
</woot-button>
</div>
</div>
</div>
</button>
</template>
<script>
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default {
name: 'ArticleSearchResultItem',
mixins: [alertMixin],
props: {
id: {
type: Number,
@@ -92,13 +75,16 @@ export default {
},
},
methods: {
handleInsert() {
handleInsert(e) {
e.stopPropagation();
this.$emit('insert', this.id);
},
handlePreview() {
handlePreview(e) {
e.stopPropagation();
this.$emit('preview', this.id);
},
async handleCopy() {
async handleCopy(e) {
e.stopPropagation();
await copyTextToClipboard(this.url);
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},

View File

@@ -10,8 +10,10 @@
{{ $t('HELP_CENTER.ARTICLE_SEARCH.BACK_RESULTS') }}
</woot-button>
</div>
<div class="w-full h-full overflow-auto min-h-0">
<iframe-loader :url="url" />
<div class="-ml-4 h-full overflow-y-auto">
<div class="w-full h-full min-h-0">
<iframe-loader :url="url" />
</div>
</div>
<div class="flex justify-end gap-2 py-2">
@@ -41,7 +43,7 @@
import IframeLoader from 'shared/components/IframeLoader.vue';
export default {
name: 'ChatwootSearch',
name: 'ArticleView',
components: {
IframeLoader,
},
@@ -52,10 +54,12 @@ export default {
},
},
methods: {
onBack() {
onBack(e) {
e.stopPropagation();
this.$emit('back');
},
onInsert() {
onInsert(e) {
e.stopPropagation();
this.$emit('insert');
},
},

View File

@@ -18,10 +18,13 @@
<fluent-icon icon="search" class="" size="16" />
</div>
<input
ref="searchInput"
type="text"
:placeholder="$t('HELP_CENTER.ARTICLE_SEARCH.PLACEHOLDER')"
class="block w-full pl-8 h-8 text-sm dark:bg-slate-700 bg-slate-25 rounded-md leading-8 py-1 text-slate-700 shadow-sm ring-2 ring-transparent ring-slate-300 border border-solid border-slate-300 placeholder:text-slate-400 focus:border-woot-600 focus:ring-2 focus:ring-woot-100 mb-0 focus:bg-slate-25 dark:focus:bg-slate-700"
class="block w-full pl-8 h-8 text-sm dark:bg-slate-700 bg-slate-25 rounded-md leading-8 py-1 text-slate-700 shadow-sm ring-2 ring-transparent ring-slate-300 border border-solid border-slate-300 placeholder:text-slate-400 focus:border-woot-600 focus:ring-woot-200 mb-0 focus:bg-slate-25 dark:focus:bg-slate-700 dark:focus:ring-woot-700"
:value="searchQuery"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
</div>
@@ -29,8 +32,15 @@
</template>
<script>
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
buildHotKeys,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default {
name: 'ChatwootSearch',
mixins: [eventListenerMixins],
props: {
title: {
type: String,
@@ -40,8 +50,12 @@ export default {
data() {
return {
searchQuery: '',
isInputFocused: false,
};
},
mounted() {
this.$refs.searchInput.focus();
},
methods: {
onInput(e) {
this.$emit('search', e.target.value);
@@ -49,6 +63,20 @@ export default {
onClose() {
this.$emit('close');
},
onFocus() {
this.isInputFocused = true;
},
onBlur() {
this.isInputFocused = false;
},
handleKeyEvents(e) {
const keyPattern = buildHotKeys(e);
if (keyPattern === '/' && !isActiveElementTypeable(e)) {
e.preventDefault();
this.$refs.searchInput.focus();
}
},
},
};
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div
class="fixed flex items-center justify-center w-screen h-screen bg-white/70 top-0 left-0 z-50"
>
<div
v-on-clickaway="onClose"
class="flex flex-col px-4 pb-4 rounded-md shadow-md border border-solid border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-900 z-[1000] max-w-[720px] md:w-[20rem] lg:w-[24rem] xl:w-[28rem] 2xl:w-[32rem] h-[calc(100vh-20rem)] max-h-[40rem]"
>
<search-header
:title="$t('HELP_CENTER.ARTICLE_SEARCH.TITLE')"
class="w-full sticky top-0 bg-[inherit]"
@close="onClose"
@search="onSearch"
/>
<article-view
v-if="activeId"
:url="articleViewerUrl"
@back="onBack"
@insert="onInsert"
/>
<search-results
v-else
:search-query="searchQuery"
:is-loading="isLoading"
:portal-slug="selectedPortalSlug"
:articles="searchResultsWithUrl"
@preview="handlePreview"
@insert="onInsert"
/>
</div>
</div>
</template>
<script>
import { debounce } from '@chatwoot/utils';
import { mixin as clickaway } from 'vue-clickaway';
import {
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue';
import ArticleView from './ArticleView.vue';
import ArticlesAPI from 'dashboard/api/helpCenter/articles';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import portalMixin from '../../mixins/portalMixin';
import alertMixin from 'shared/mixins/alertMixin';
export default {
name: 'ArticleSearchPopover',
components: {
SearchHeader,
SearchResults,
ArticleView,
},
mixins: [clickaway, portalMixin, alertMixin],
props: {
selectedPortalSlug: {
type: String,
required: true,
},
},
data() {
return {
searchQuery: '',
isLoading: false,
searchResults: [],
activeId: '',
debounceSearch: () => {},
};
},
computed: {
articleViewerUrl() {
const article = this.activeArticle(this.activeId);
if (!article) return '';
const isDark = document.body.classList.contains('dark');
const url = new URL(article.url);
url.searchParams.set('show_plain_layout', 'true');
if (isDark) {
url.searchParams.set('theme', 'dark');
}
return `${url}`;
},
searchResultsWithUrl() {
return this.searchResults.map(article => ({
...article,
localeName: this.localeName(article.category.locale || 'en'),
url: this.generateArticleUrl(article),
}));
},
},
mounted() {
this.fetchArticlesByQuery(this.searchQuery);
this.debounceSearch = debounce(this.fetchArticlesByQuery, 500, false);
document.body.addEventListener('keydown', this.closeOnEsc);
},
beforeDestroy() {
document.body.removeEventListener('keydown', this.closeOnEsc);
},
methods: {
generateArticleUrl(article) {
return buildPortalArticleURL(
this.selectedPortalSlug,
'',
'',
article.slug
);
},
activeArticle(id) {
return this.searchResultsWithUrl.find(article => article.id === id);
},
onSearch(query) {
this.searchQuery = query;
this.activeId = '';
this.debounceSearch(query);
},
onClose() {
this.$emit('close');
this.searchQuery = '';
this.activeId = '';
this.searchResults = [];
},
async fetchArticlesByQuery(query) {
try {
const sort = query ? '' : 'views';
this.isLoading = true;
this.searchResults = [];
const { data } = await ArticlesAPI.searchArticles({
portalSlug: this.selectedPortalSlug,
query,
sort,
});
this.searchResults = data.payload;
this.isLoading = true;
} catch (error) {
// Show something wrong message
} finally {
this.isLoading = false;
}
},
handlePreview(id) {
this.activeId = id;
},
onBack() {
this.activeId = '';
},
onInsert(id) {
const article = this.activeArticle(id || this.activeId);
this.$emit('insert', article);
this.showAlert(
this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED')
);
this.onClose();
},
closeOnEsc(e) {
if (isEscape(e) && !isActiveElementTypeable(e)) {
e.preventDefault();
this.onClose();
}
},
},
};
</script>

View File

@@ -1,8 +1,9 @@
<template>
<div class="flex justify-end gap-1 py-4 bg-white dark:bg-slate-900">
<div
class="flex justify-end gap-1 py-4 bg-white dark:bg-slate-900 h-full overflow-y-auto"
>
<div class="flex flex-col gap-1 w-full">
<div v-if="isLoading" class="empty-state-message">
<spinner />
{{ $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.SEARCH_LOADER') }}
</div>
<div v-else-if="showNoResults" class="empty-state-message">
@@ -17,7 +18,7 @@
:body="article.content"
:url="article.url"
:category="article.category.name"
:locale="article.category.locale"
:locale="article.localeName"
@preview="handlePreview"
@insert="handleInsert"
/>
@@ -26,13 +27,11 @@
</template>
<script>
import Spinner from 'shared/components/Spinner.vue';
import SearchResultItem from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/ArticleSearchResultItem.vue';
import SearchResultItem from './ArticleSearchResultItem.vue';
export default {
name: 'SearchResults',
components: {
Spinner,
SearchResultItem,
},
props: {