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:
committed by
GitHub
parent
b4d20689b7
commit
39d0748a5b
@@ -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'));
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user