feat: Add search functionality for public portal (#5683)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
committed by
GitHub
parent
bce0bb8acb
commit
1fb1be3ddc
@@ -1,7 +1,7 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < PublicController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :set_category
|
||||
before_action :set_category, except: [:index]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
@@ -20,7 +20,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
|
||||
end
|
||||
|
||||
def set_category
|
||||
@category = @portal.categories.find_by!(slug: params[:category_slug])
|
||||
@category = @portal.categories.find_by!(slug: params[:category_slug]) if params[:category_slug].present?
|
||||
end
|
||||
|
||||
def portal
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
// a relevant structure within app/javascript and only use these pack files to reference
|
||||
// that code so that it will be compiled.
|
||||
|
||||
import Vue from 'vue';
|
||||
import Rails from '@rails/ujs';
|
||||
import Turbolinks from 'turbolinks';
|
||||
import PublicArticleSearch from '../portal/components/PublicArticleSearch.vue';
|
||||
|
||||
import { navigateToLocalePage } from '../portal/portalHelpers';
|
||||
|
||||
@@ -13,4 +15,21 @@ import '../portal/application.scss';
|
||||
Rails.start();
|
||||
Turbolinks.start();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', navigateToLocalePage);
|
||||
const initPageSetUp = () => {
|
||||
navigateToLocalePage();
|
||||
const isSearchContainerAvailable = document.querySelector('#search-wrap');
|
||||
if (isSearchContainerAvailable) {
|
||||
new Vue({
|
||||
components: { PublicArticleSearch },
|
||||
template: '<PublicArticleSearch />',
|
||||
}).$mount('#search-wrap');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initPageSetUp();
|
||||
});
|
||||
|
||||
document.addEventListener('turbolinks:load', () => {
|
||||
initPageSetUp();
|
||||
});
|
||||
|
||||
14
app/javascript/portal/api/article.js
Normal file
14
app/javascript/portal/api/article.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import axios from 'axios';
|
||||
|
||||
class ArticlesAPI {
|
||||
constructor() {
|
||||
this.baseUrl = '';
|
||||
}
|
||||
|
||||
searchArticles(portalSlug, locale, query) {
|
||||
let baseUrl = `${this.baseUrl}/hc/${portalSlug}/${locale}/articles.json?query=${query}`;
|
||||
return axios.get(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArticlesAPI();
|
||||
129
app/javascript/portal/components/PublicArticleSearch.vue
Normal file
129
app/javascript/portal/components/PublicArticleSearch.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div
|
||||
v-on-clickaway="closeSearch"
|
||||
class="mx-auto max-w-md w-full relative my-4"
|
||||
>
|
||||
<public-search-input
|
||||
v-model="searchTerm"
|
||||
:search-placeholder="searchTranslations.searchPlaceholder"
|
||||
@focus="openSearch"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldShowSearchBox"
|
||||
class="absolute show-search-box w-full"
|
||||
@mouseover="openSearch"
|
||||
>
|
||||
<search-suggestions
|
||||
:items="searchResults"
|
||||
:is-loading="isLoading"
|
||||
:empty-placeholder="searchTranslations.emptyPlaceholder"
|
||||
:results-title="searchTranslations.resultsTitle"
|
||||
:loading-placeholder="searchTranslations.loadingPlaceholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
import SearchSuggestions from './SearchSuggestions';
|
||||
import PublicSearchInput from './PublicSearchInput';
|
||||
|
||||
import ArticlesAPI from '../api/article';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PublicSearchInput,
|
||||
SearchSuggestions,
|
||||
},
|
||||
mixins: [clickaway],
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchTerm: '',
|
||||
isLoading: false,
|
||||
showSearchBox: false,
|
||||
searchResults: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
portalSlug() {
|
||||
return window.portalConfig.portalSlug;
|
||||
},
|
||||
localeCode() {
|
||||
return window.portalConfig.localeCode;
|
||||
},
|
||||
shouldShowSearchBox() {
|
||||
return this.searchTerm !== '' && this.showSearchBox;
|
||||
},
|
||||
searchTranslations() {
|
||||
const { searchTranslations = {} } = window.portalConfig;
|
||||
return searchTranslations;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
searchTerm() {
|
||||
if (this.typingTimer) {
|
||||
clearTimeout(this.typingTimer);
|
||||
}
|
||||
|
||||
this.openSearch();
|
||||
this.isLoading = true;
|
||||
this.typingTimer = setTimeout(() => {
|
||||
this.fetchArticlesByQuery();
|
||||
}, 1000);
|
||||
},
|
||||
currentPage() {
|
||||
this.clearSearchTerm();
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChange(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
onBlur(e) {
|
||||
this.$emit('blur', e.target.value);
|
||||
},
|
||||
openSearch() {
|
||||
this.showSearchBox = true;
|
||||
},
|
||||
closeSearch() {
|
||||
this.showSearchBox = false;
|
||||
},
|
||||
clearSearchTerm() {
|
||||
this.searchTerm = '';
|
||||
},
|
||||
async fetchArticlesByQuery() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.searchResults = [];
|
||||
const { data } = await ArticlesAPI.searchArticles(
|
||||
this.portalSlug,
|
||||
this.localeCode,
|
||||
this.searchTerm
|
||||
);
|
||||
this.searchResults = data.payload;
|
||||
this.isLoading = true;
|
||||
} catch (error) {
|
||||
// Show something wrong message
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.show-search-box {
|
||||
top: 4rem;
|
||||
}
|
||||
</style>
|
||||
60
app/javascript/portal/components/PublicSearchInput.vue
Normal file
60
app/javascript/portal/components/PublicSearchInput.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-full flex items-center rounded-md border-solid h-16 bg-white px-4 py-2 text-slate-600"
|
||||
:class="{
|
||||
'shadow border-2 border-woot-100': isFocused,
|
||||
'border border-slate-50 shadow-sm': !isFocused,
|
||||
}"
|
||||
>
|
||||
<fluent-icon icon="search" />
|
||||
<input
|
||||
:value="value"
|
||||
type="text"
|
||||
class="w-full search-input focus:outline-none text-base h-full bg-white px-2 py-2
|
||||
text-slate-700 placeholder-slate-500 sm:text-sm"
|
||||
:placeholder="searchPlaceholder"
|
||||
role="search"
|
||||
@input="onChange"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FluentIcon,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onChange(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
onFocus(e) {
|
||||
this.isFocused = true;
|
||||
this.$emit('focus', e.target.value);
|
||||
},
|
||||
onBlur(e) {
|
||||
this.isFocused = false;
|
||||
this.$emit('blur', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
103
app/javascript/portal/components/SearchSuggestions.vue
Normal file
103
app/javascript/portal/components/SearchSuggestions.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div
|
||||
class="shadow-md bg-white mt-2 max-h-72 scroll-py-2 p-4 rounded overflow-y-auto text-sm text-slate-700"
|
||||
>
|
||||
<div v-if="isLoading" class="font-medium text-sm text-slate-400">
|
||||
{{ loadingPlaceholder }}
|
||||
</div>
|
||||
<h3 v-if="shouldShowResults" class="font-medium text-sm text-slate-400">
|
||||
{{ resultsTitle }}
|
||||
</h3>
|
||||
<ul
|
||||
v-if="shouldShowResults"
|
||||
class="bg-white mt-2 max-h-72 scroll-py-2 overflow-y-auto text-sm text-slate-700"
|
||||
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': index === selectedIndex }"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
@mouseover="onHover(index)"
|
||||
>
|
||||
<a
|
||||
:href="generateArticleUrl(article)"
|
||||
class="flex-auto truncate text-base font-medium leading-6 w-full hover:underline"
|
||||
>
|
||||
{{ article.title }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="showEmptyResults" class="font-medium text-sm text-slate-400">
|
||||
{{ emptyPlaceholder }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import mentionSelectionKeyboardMixin from 'dashboard/components/widgets/mentions/mentionSelectionKeyboardMixin.js';
|
||||
|
||||
export default {
|
||||
mixins: [mentionSelectionKeyboardMixin],
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emptyPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loadingPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
resultsTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 0,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
showEmptyResults() {
|
||||
return !this.items.length && !this.isLoading;
|
||||
},
|
||||
shouldShowResults() {
|
||||
return this.items.length && !this.isLoading;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateArticleUrl(article) {
|
||||
return `/hc/${article.portal.slug}/${article.category.locale}/${article.category.slug}/${article.id}`;
|
||||
},
|
||||
handleKeyboardEvent(e) {
|
||||
this.processKeyDownEvent(e);
|
||||
this.$el.scrollTop = 40 * this.selectedIndex;
|
||||
},
|
||||
onHover(index) {
|
||||
this.selectedIndex = index;
|
||||
},
|
||||
onSelect() {
|
||||
window.location = this.generateArticleUrl(this.items[this.selectedIndex]);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,8 +1,13 @@
|
||||
export const navigateToLocalePage = () => {
|
||||
const allLocaleSwitcher = document.querySelector('.locale-switcher');
|
||||
|
||||
if (!allLocaleSwitcher) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { portalSlug } = allLocaleSwitcher.dataset;
|
||||
allLocaleSwitcher.addEventListener('change', event => {
|
||||
window.location = `/hc/${portalSlug}/${event.target.value}/`;
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"link-outline": "M9.25 7a.75.75 0 0 1 .11 1.492l-.11.008H7a3.5 3.5 0 0 0-.206 6.994L7 15.5h2.25a.75.75 0 0 1 .11 1.492L9.25 17H7a5 5 0 0 1-.25-9.994L7 7h2.25ZM17 7a5 5 0 0 1 .25 9.994L17 17h-2.25a.75.75 0 0 1-.11-1.492l.11-.008H17a3.5 3.5 0 0 0 .206-6.994L17 8.5h-2.25a.75.75 0 0 1-.11-1.492L14.75 7H17ZM7 11.25h10a.75.75 0 0 1 .102 1.493L17 12.75H7a.75.75 0 0 1-.102-1.493L7 11.25h10H7Z",
|
||||
"more-vertical-outline": "M12 7.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM12 13.75a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5ZM10.25 18a1.75 1.75 0 1 0 3.5 0 1.75 1.75 0 0 0-3.5 0Z",
|
||||
"open-outline": "M6.25 4.5A1.75 1.75 0 0 0 4.5 6.25v11.5c0 .966.783 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-4a.75.75 0 0 1 1.5 0v4A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h4a.75.75 0 0 1 0 1.5h-4ZM13 3.75a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0V5.56l-5.22 5.22a.75.75 0 0 1-1.06-1.06l5.22-5.22h-4.69a.75.75 0 0 1-.75-.75Z",
|
||||
"search-outline": "M10 2.75a7.25 7.25 0 0 1 5.63 11.819l4.9 4.9a.75.75 0 0 1-.976 1.134l-.084-.073-4.901-4.9A7.25 7.25 0 1 1 10 2.75Zm0 1.5a5.75 5.75 0 1 0 0 11.5 5.75 5.75 0 0 0 0-11.5Z",
|
||||
"send-outline": "M5.694 12 2.299 3.272c-.236-.607.356-1.188.942-.982l.093.04 18 9a.75.75 0 0 1 .097 1.283l-.097.058-18 9c-.583.291-1.217-.244-1.065-.847l.03-.096L5.694 12 2.299 3.272 5.694 12ZM4.402 4.54l2.61 6.71h6.627a.75.75 0 0 1 .743.648l.007.102a.75.75 0 0 1-.649.743l-.101.007H7.01l-2.609 6.71L19.322 12 4.401 4.54Z",
|
||||
"sign-out-outline": ["M8.502 11.5a1.002 1.002 0 1 1 0 2.004 1.002 1.002 0 0 1 0-2.004Z","M12 4.354v6.651l7.442-.001L17.72 9.28a.75.75 0 0 1-.073-.976l.073-.084a.75.75 0 0 1 .976-.073l.084.073 2.997 2.997a.75.75 0 0 1 .073.976l-.073.084-2.996 3.004a.75.75 0 0 1-1.134-.975l.072-.085 1.713-1.717-7.431.001L12 19.25a.75.75 0 0 1-.88.739l-8.5-1.502A.75.75 0 0 1 2 17.75V5.75a.75.75 0 0 1 .628-.74l8.5-1.396a.75.75 0 0 1 .872.74Zm-1.5.883-7 1.15V17.12l7 1.236V5.237Z","M13 18.501h.765l.102-.006a.75.75 0 0 0 .648-.745l-.007-4.25H13v5.001ZM13.002 10 13 8.725V5h.745a.75.75 0 0 1 .743.647l.007.102.007 4.251h-1.5Z"]
|
||||
"sign-out-outline": ["M8.502 11.5a1.002 1.002 0 1 1 0 2.004 1.002 1.002 0 0 1 0-2.004Z", "M12 4.354v6.651l7.442-.001L17.72 9.28a.75.75 0 0 1-.073-.976l.073-.084a.75.75 0 0 1 .976-.073l.084.073 2.997 2.997a.75.75 0 0 1 .073.976l-.073.084-2.996 3.004a.75.75 0 0 1-1.134-.975l.072-.085 1.713-1.717-7.431.001L12 19.25a.75.75 0 0 1-.88.739l-8.5-1.502A.75.75 0 0 1 2 17.75V5.75a.75.75 0 0 1 .628-.74l8.5-1.396a.75.75 0 0 1 .872.74Zm-1.5.883-7 1.15V17.12l7 1.236V5.237Z", "M13 18.501h.765l.102-.006a.75.75 0 0 0 .648-.745l-.007-4.25H13v5.001ZM13.002 10 13 8.725V5h.745a.75.75 0 0 1 .743.647l.007.102.007 4.251h-1.5Z"]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ By default, it renders:
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="initial-scale=1">
|
||||
<meta name= "turbolinks-cache-control" content= "no-cache">
|
||||
<%= javascript_pack_tag 'portal' %>
|
||||
<%= stylesheet_pack_tag 'portal' %>
|
||||
<%= csrf_meta_tags %>
|
||||
@@ -35,4 +36,16 @@ By default, it renders:
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
<script>
|
||||
window.portalConfig = {
|
||||
portalSlug: '<%= @portal.slug %>',
|
||||
localeCode: '<%= @locale %>',
|
||||
searchTranslations: {
|
||||
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',
|
||||
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
|
||||
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
|
||||
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -13,6 +13,14 @@ if article.portal.present?
|
||||
end
|
||||
end
|
||||
|
||||
if article.category.present?
|
||||
json.category do
|
||||
json.id article.category.id
|
||||
json.slug article.category.slug
|
||||
json.locale article.category.locale
|
||||
end
|
||||
end
|
||||
|
||||
json.views article.views
|
||||
|
||||
if article.author.present?
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<footer class="pt-16 pb-8 flex flex-col items-center justify-center">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<p class="text-slate-700 py-2 text-center">
|
||||
Made with <a class="hover:underline" href="https://www.chatwoot.com" target="_blank" rel="noopener noreferrer nofoll/ow">Chatwoot</a> 💙.
|
||||
Made with <a class="hover:underline" href="https://www.chatwoot.com" target="_blank" rel="noopener noreferrer nofoll/ow">Chatwoot</a> 💙
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="inline-flex relative w-24">
|
||||
<select
|
||||
data-portal-slug="<%= @portal.slug %>"
|
||||
class="appearance-none w-full bg-white px-3 py-2 pr-8 rounded leading-tight focus:outline-none focus:shadow-outline locale-switcher"
|
||||
class="appearance-none w-full bg-white px-3 py-2 pr-8 rounded leading-tight focus:outline-none focus:shadow-outline locale-switcher hover:bg-slate-75 cursor-pointer"
|
||||
>
|
||||
<% @portal.config["allowed_locales"].each do |locale| %>
|
||||
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= locale %></option>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
|
||||
<section class="bg-gradient-to-b from-white to-black-50 pt-8 pb-16 md:py-16 flex flex-col items-center justify-center">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-2xl px-5 md:text-4xl text-slate-900 font-semibold subpixel-antialiased leading-relaxed text-center">
|
||||
<section class="bg-gradient-to-b from-white to-slate-75 pt-8 pb-16 md:py-16 flex flex-col items-center justify-center">
|
||||
<div class="mx-auto w-full max-w-2xl flex flex-col items-center">
|
||||
<h1 class="text-2xl px-5 md:text-4xl text-slate-900 font-semibold subpixel-antialiased leading-normal text-center">
|
||||
<%= portal.header_text %>
|
||||
</h1>
|
||||
<p class="text-slate-700 py-2 text-center mt-10">Browse the categories below</p>
|
||||
<p class="text-slate-600 py-2 text-center my-2 max-w-sm leading-normal"><%= I18n.t('public_portal.hero.sub_title') %></p>
|
||||
<div id="search-wrap"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
<div class="max-w-4xl px-6 py-16 mx-auto space-y-12 w-full">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<a class="text-slate-800 hover:underline leading-8"
|
||||
href="/hc/<%= @portal.slug %>/<%= @category.slug %>" class=""><%= @portal.name %> Home</a>
|
||||
<a
|
||||
class="text-slate-800 hover:underline leading-8"
|
||||
href="/hc/<%= @portal.slug %>/<%= @category.present? ? @category.slug : '' %>"
|
||||
>
|
||||
<%= @portal.name %> Home
|
||||
</a>
|
||||
<span>/</span>
|
||||
<span>/</span>
|
||||
</div>
|
||||
@@ -23,7 +27,6 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="max-w-4xl flex-grow w-full px-6 py-16 mx-auto space-y-12">
|
||||
<article class="space-y-8 ">
|
||||
|
||||
Reference in New Issue
Block a user