feat: Implemented search results page functionality (#11086)
# Pull Request Template
## Description
Implemented search results page functionality. Now you can press "Enter"
to search by term and display results in a results page. Also now you
can link to /hc/{account}/en/search?query=XXXXXX to view search results
for XXXXXX query.
fixes: https://github.com/chatwoot/chatwoot/issues/10945
## Screenshots
Classic layout search results:
<img width="3840" height="2160" alt="classic-results"
src="https://github.com/user-attachments/assets/3bbb3272-33ca-4eb4-b80a-76ed77442088"
/>
Classic layout pagination:
<img width="3840" height="2160" alt="classic-page-two"
src="https://github.com/user-attachments/assets/062b09d3-7c58-4d3b-8611-b94375e7db51"
/>
Classic layout empty search:
<img width="3840" height="2160" alt="no-results"
src="https://github.com/user-attachments/assets/c5e3f47a-cd9a-4e14-ae92-ccba00c89e98"
/>
Documentation layout search results:
<img width="3840" height="2160" alt="documentation-results"
src="https://github.com/user-attachments/assets/9e45d8d9-c975-4589-b6c6-3bc7bb3c588e"
/>
Documentation layout dark theme:
<img width="3840" height="2160" alt="documentation-dark"
src="https://github.com/user-attachments/assets/cdb6ed63-4241-4b32-9f79-7d92ed479fc8"
/>
Plain embedded dark layout:
<img width="3840" height="2160" alt="plain-embedded-dark"
src="https://github.com/user-attachments/assets/7deb02b9-9f24-48fb-8979-a2ecd7002c05"
/>
---------
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
This commit is contained in:
co-authored by
Shivam Mishra
Sojan Jose
Pranav
Muhsin Keloth
Vinay Keerthi
parent
88e2661ca6
commit
ecd9c26c8c
@@ -0,0 +1,31 @@
|
||||
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:index]
|
||||
before_action :portal
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@query = params[:query].to_s.strip
|
||||
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
|
||||
|
||||
search_articles
|
||||
|
||||
@articles = @articles.page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_articles
|
||||
@articles = @query.present? ? @articles.search(search_params) : @articles.none
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
|
||||
permitted[:query] = @query
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
|
||||
@@ -45,9 +45,16 @@ module PortalHelper
|
||||
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
|
||||
end
|
||||
|
||||
def portal_query_string(theme, is_plain_layout_enabled)
|
||||
query_params = {}
|
||||
query_params[:theme] = theme if theme.present? && theme != 'system'
|
||||
query_params[:show_plain_layout] = true if is_plain_layout_enabled
|
||||
query_params.present? ? "?#{query_params.to_query}" : ''
|
||||
end
|
||||
|
||||
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
|
||||
"/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/#{portal_locale}"
|
||||
end
|
||||
@@ -61,7 +68,7 @@ module PortalHelper
|
||||
is_plain_layout_enabled = params[:is_plain_layout_enabled]
|
||||
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
|
||||
end
|
||||
@@ -69,7 +76,7 @@ module PortalHelper
|
||||
|
||||
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}"
|
||||
end
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Rails from '@rails/ujs';
|
||||
import Turbolinks from 'turbolinks';
|
||||
import { Turbo } from '@hotwired/turbo-rails';
|
||||
import '../portal/application.scss';
|
||||
import { InitializationHelpers } from '../portal/portalHelpers';
|
||||
|
||||
Rails.start();
|
||||
Turbolinks.start();
|
||||
Turbo.start();
|
||||
|
||||
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
|
||||
document.addEventListener('turbo:load', InitializationHelpers.onLoad);
|
||||
|
||||
@@ -55,6 +55,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.turbolinks-progress-bar {
|
||||
.turbo-progress-bar {
|
||||
background-color: var(--dynamic-portal-color);
|
||||
}
|
||||
|
||||
@@ -138,21 +138,40 @@ export default {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
handleSubmit() {
|
||||
const query = this.normalizedSearchTerm;
|
||||
if (!query) return;
|
||||
|
||||
const searchParams = new URLSearchParams({ query });
|
||||
const { theme, isPlainLayoutEnabled } = window.portalConfig;
|
||||
|
||||
if (theme) searchParams.set('theme', theme);
|
||||
if (isPlainLayoutEnabled === 'true') {
|
||||
searchParams.set('show_plain_layout', 'true');
|
||||
}
|
||||
|
||||
window.location.href = `/hc/${this.portalSlug}/${this.localeCode}/search?${searchParams.toString()}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
|
||||
<PublicSearchInput
|
||||
ref="searchInput"
|
||||
:search-term="searchTerm"
|
||||
:search-placeholder="searchTranslations.searchPlaceholder"
|
||||
:size="size"
|
||||
:kbd="kbdLabel"
|
||||
@update:search-term="onUpdateSearchTerm"
|
||||
@focus="openSearch"
|
||||
/>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<PublicSearchInput
|
||||
ref="searchInput"
|
||||
:search-term="searchTerm"
|
||||
:search-placeholder="searchTranslations.searchPlaceholder"
|
||||
:size="size"
|
||||
:kbd="kbdLabel"
|
||||
@update:search-term="onUpdateSearchTerm"
|
||||
@focus="openSearch"
|
||||
/>
|
||||
<button type="submit" class="sr-only">
|
||||
{{ searchTranslations.submit }}
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
v-if="shouldShowSearchBox"
|
||||
class="absolute w-full top-14"
|
||||
|
||||
@@ -112,8 +112,8 @@ export default {
|
||||
<p class="py-1 px-3" :class="getClassName(element)">
|
||||
<a
|
||||
:href="`#${element.slug}`"
|
||||
data-turbolinks="false"
|
||||
class="font-medium text-sm cursor-pointer"
|
||||
data-turbo="false"
|
||||
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
|
||||
:class="elementTextStyles(element)"
|
||||
>
|
||||
{{ element.title }}
|
||||
|
||||
@@ -24,10 +24,9 @@ export const getHeadingsfromTheArticle = () => {
|
||||
permalink.className = 'permalink text-slate-600 ml-3';
|
||||
permalink.href = `#${slug}`;
|
||||
permalink.title = headingText;
|
||||
permalink.dataset.turbolinks = 'false';
|
||||
permalink.dataset.turbo = 'false';
|
||||
permalink.textContent = '#';
|
||||
element.appendChild(permalink);
|
||||
|
||||
rows.push({
|
||||
slug,
|
||||
title: headingText,
|
||||
@@ -188,7 +187,7 @@ export const InitializationHelpers = {
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = window.location.hash;
|
||||
a['data-turbolinks'] = false;
|
||||
a['data-turbo'] = false;
|
||||
a.click();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -55,10 +55,14 @@ class Article < ApplicationRecord
|
||||
before_validation :ensure_article_slug
|
||||
before_validation :ensure_locale_in_article
|
||||
|
||||
# Slugs that collide with help center routes (e.g. /hc/:slug/:locale/search)
|
||||
RESERVED_SLUGS = %w[search articles categories].freeze
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :author_id, presence: true
|
||||
validates :title, presence: true
|
||||
validates :content, presence: true, if: :published?
|
||||
validates :slug, exclusion: { in: RESERVED_SLUGS }
|
||||
|
||||
# ensuring that the position is always set correctly
|
||||
before_create :add_position_to_article
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="initial-scale=1">
|
||||
<meta name="turbolinks-cache-control" content="no-cache">
|
||||
<meta name="turbo-cache-control" content="no-cache">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<%= vite_client_tag %>
|
||||
|
||||
@@ -82,6 +82,7 @@ html.light {
|
||||
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
|
||||
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
|
||||
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
|
||||
submit: '<%= I18n.t('public_portal.search.submit') %>',
|
||||
},
|
||||
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
|
||||
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<%= I18n.t('public_portal.hero.sub_title') %>
|
||||
</p>
|
||||
|
||||
<form class="mt-8 group/herosearch relative z-30" onsubmit="return false">
|
||||
<div class="mt-8 group/herosearch relative z-30">
|
||||
<div id="search-wrap-hero" class="block w-full"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<% if popular_topics.any? %>
|
||||
<div class="mt-5 flex items-center gap-2 flex-wrap text-sm">
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<% portal.public_locale_codes.each do |code| %>
|
||||
<% is_current = code == locale %>
|
||||
<a href="/hc/<%= portal.slug %>/<%= code %>/"
|
||||
data-turbolinks="false"
|
||||
data-turbo="false"
|
||||
class="flex items-center gap-2.5 px-2.5 py-2 text-sm rounded-md transition <%= is_current ? 'bg-n-portal-soft text-n-portal' : 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12' %>">
|
||||
<span class="text-xs font-semibold uppercase min-w-9 text-center text-n-slate-11 bg-n-slate-2 border border-solid border-n-weak rounded px-1.5 py-0.5 flex-shrink-0"><%= code %></span>
|
||||
<span class="flex-1 truncate <%= is_current ? 'font-medium' : '' %>"><%= language_name(code) %></span>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<%# locals: (input_class:) %>
|
||||
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
|
||||
<input type="hidden" name="locale" value="<%= params[:locale] %>">
|
||||
<% if @theme_from_params.present? %>
|
||||
<input type="hidden" name="theme" value="<%= @theme_from_params %>">
|
||||
<% end %>
|
||||
<% if @is_plain_layout_enabled %>
|
||||
<input type="hidden" name="show_plain_layout" value="true">
|
||||
<% end %>
|
||||
<input type="text"
|
||||
name="query"
|
||||
value="<%= @query %>"
|
||||
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
|
||||
data-search-input
|
||||
autofocus
|
||||
class="<%= input_class %>">
|
||||
<button type="submit" class="sr-only">
|
||||
<%= I18n.t('public_portal.search.submit') %>
|
||||
</button>
|
||||
</form>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
(function() {
|
||||
const searchInputs = document.querySelectorAll('[data-search-input]');
|
||||
|
||||
searchInputs.forEach(function(input) {
|
||||
let debounceTimer;
|
||||
|
||||
if (input.value.length > 0) {
|
||||
setTimeout(function() {
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
const form = input.closest('[data-search-form]');
|
||||
const query = input.value.trim();
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
|
||||
debounceTimer = setTimeout(function() {
|
||||
const currentQuery = new URLSearchParams(window.location.search).get('query') || '';
|
||||
if (query !== currentQuery) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
input.closest('[data-search-form]').addEventListener('submit', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<% end %>
|
||||
|
||||
<div class="px-6 md:px-10 py-8 md:py-10">
|
||||
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
|
||||
|
||||
<nav class="flex items-center gap-2 text-sm text-n-slate-11 mb-8 flex-wrap">
|
||||
<a
|
||||
href="<%= public_portal_locale_path(@portal.slug, @locale) %>"
|
||||
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
|
||||
>
|
||||
<span class="i-lucide-house size-3.5" aria-hidden="true"></span>
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="i-lucide-chevron-right size-3 text-n-slate-9" aria-hidden="true"></span>
|
||||
<span class="text-n-slate-12 font-medium truncate"><%= I18n.t('public_portal.search.results') %></span>
|
||||
</nav>
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl md:text-4xl leading-snug font-620 tracking-tight text-n-slate-12 text-balance">
|
||||
<%= I18n.t('public_portal.search.results_for', query: @query) %>
|
||||
</h1>
|
||||
<%= render 'public/api/v1/portals/search/form',
|
||||
input_class: 'w-full px-4 py-3 border border-n-weak rounded-lg bg-n-alpha-1 text-n-slate-12 placeholder-n-slate-10 focus:outline-none focus:ring-2 focus:ring-n-portal focus:border-transparent' %>
|
||||
</div>
|
||||
|
||||
<%= render 'public/api/v1/portals/search/search_handler' %>
|
||||
|
||||
<% if @articles.empty? %>
|
||||
<%= render 'public/api/v1/portals/documentation_layout/empty_state',
|
||||
message: I18n.t('public_portal.search.no_results', query: @query) %>
|
||||
<% else %>
|
||||
<p class="mb-6 text-sm text-n-slate-11">
|
||||
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<% @articles.each do |article| %>
|
||||
<%= render 'public/api/v1/portals/documentation_layout/article_card',
|
||||
portal: @portal,
|
||||
article: article %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
|
||||
<div class="flex justify-center mt-6">
|
||||
<nav class="inline-flex">
|
||||
<% if @articles.prev_page %>
|
||||
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-n-weak rounded-l-md text-sm font-medium text-n-slate-11 hover:bg-n-alpha-2">
|
||||
<%= I18n.t('public_portal.common.previous') %>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
<% if @articles.next_page %>
|
||||
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-n-weak rounded-r-md text-sm font-medium text-n-slate-11 hover:bg-n-alpha-2">
|
||||
<%= I18n.t('public_portal.common.next') %>
|
||||
</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,118 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<% end %>
|
||||
|
||||
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
<%= I18n.t('public_portal.search.results') %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= I18n.t('public_portal.search.results_for', query: @query) %>
|
||||
</h1>
|
||||
|
||||
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
<%= I18n.t('public_portal.search.results') %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= I18n.t('public_portal.search.results_for', query: @query) %>
|
||||
</h1>
|
||||
|
||||
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
|
||||
|
||||
<%= render 'public/api/v1/portals/search/search_handler' %>
|
||||
|
||||
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||
<div class="w-full flex flex-col gap-6 flex-grow">
|
||||
<% if @articles.empty? %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
||||
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
|
||||
</p>
|
||||
|
||||
<% @articles.each do |article| %>
|
||||
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
|
||||
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
|
||||
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
|
||||
<%= article.title %>
|
||||
</h3>
|
||||
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
|
||||
<%= render_category_content(article.content) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<% if article.category.present? %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
|
||||
<%= article.category.name %>
|
||||
</span>
|
||||
<span class="text-slate-600 dark:text-slate-400">•</span>
|
||||
<% end %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
|
||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
|
||||
<div class="flex justify-center mt-6">
|
||||
<nav class="inline-flex">
|
||||
<% if @articles.prev_page %>
|
||||
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<%= I18n.t('public_portal.common.previous') %>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
<% if @articles.next_page %>
|
||||
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<%= I18n.t('public_portal.common.next') %>
|
||||
</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
@@ -427,6 +427,13 @@ en:
|
||||
empty_placeholder: No results found.
|
||||
loading_placeholder: Searching...
|
||||
results_title: Search results
|
||||
results: Search Results
|
||||
results_for: "Search Results for '%{query}'"
|
||||
no_results: "No results found for '%{query}'"
|
||||
found_results:
|
||||
one: Found 1 result
|
||||
other: 'Found %{count} results'
|
||||
submit: Search
|
||||
toc_header: 'On this page'
|
||||
sidebar:
|
||||
help_center: Help Center
|
||||
@@ -458,6 +465,8 @@ en:
|
||||
others: others
|
||||
by: By
|
||||
no_articles: There are no articles here
|
||||
previous: Previous
|
||||
next: Next
|
||||
article_actions:
|
||||
label: Open in
|
||||
view_markdown: View as Markdown
|
||||
|
||||
@@ -590,6 +590,7 @@ Rails.application.routes.draw do
|
||||
get 'hc/:slug', to: 'public/api/v1/portals#show'
|
||||
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
|
||||
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show', as: :public_portal_locale
|
||||
get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search
|
||||
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
|
||||
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
|
||||
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
module Enterprise::Public::Api::V1::Portals::SearchController
|
||||
private
|
||||
|
||||
def search_articles
|
||||
return super if @query.blank? || !@portal.account.feature_enabled?('help_center_embedding_search')
|
||||
|
||||
@articles = @articles.vector_search(search_params.merge(account_id: @portal.account_id, limit: nil))
|
||||
end
|
||||
end
|
||||
@@ -26,13 +26,15 @@ module Enterprise::Concerns::Article
|
||||
# if using add the filter block to the below query
|
||||
# .filter { |ae| ae.neighbor_distance <= distance_threshold }
|
||||
|
||||
article_ids = ArticleEmbedding.where(article_id: filtered_article_ids)
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
.limit(5)
|
||||
.pluck(:article_id)
|
||||
limit = params.key?(:limit) ? params[:limit] : 5
|
||||
|
||||
article_embeddings = ArticleEmbedding.where(article_id: filtered_article_ids)
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
article_embeddings = article_embeddings.limit(limit) if limit.present?
|
||||
article_ids = article_embeddings.pluck(:article_id)
|
||||
|
||||
# Fetch the articles by the IDs obtained from the nearest neighbors search
|
||||
where(id: article_ids)
|
||||
where(id: article_ids).in_order_of(:id, article_ids)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+1
-1
@@ -40,6 +40,7 @@
|
||||
"@formkit/vue": "^1.7.2",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@hotwired/turbo-rails": "^8.0.13",
|
||||
"@iconify-json/fluent": "^1.2.32",
|
||||
"@iconify-json/material-symbols": "^1.2.10",
|
||||
"@lk77/vue3-color": "^3.0.6",
|
||||
@@ -92,7 +93,6 @@
|
||||
"snakecase-keys": "^8.0.1",
|
||||
"timezone-phone-codes": "^0.0.2",
|
||||
"tinykeys": "^3.0.0",
|
||||
"turbolinks": "^5.2.0",
|
||||
"urlpattern-polyfill": "^10.0.0",
|
||||
"video.js": "7.21.1",
|
||||
"videojs-record": "4.5.0",
|
||||
|
||||
Generated
+22
-8
@@ -43,6 +43,9 @@ importers:
|
||||
'@highlightjs/vue-plugin':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
|
||||
'@hotwired/turbo-rails':
|
||||
specifier: ^8.0.13
|
||||
version: 8.0.13
|
||||
'@iconify-json/fluent':
|
||||
specifier: ^1.2.32
|
||||
version: 1.2.36
|
||||
@@ -199,9 +202,6 @@ importers:
|
||||
tinykeys:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
turbolinks:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0
|
||||
urlpattern-polyfill:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
@@ -942,6 +942,13 @@ packages:
|
||||
'@histoire/vendors@0.17.17':
|
||||
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
|
||||
|
||||
'@hotwired/turbo-rails@8.0.13':
|
||||
resolution: {integrity: sha512-6SCnnOSzhtaJ0pNkAjncZxjtKsK3sP/vPEkCnTXBXSHkr+vF7DTZkOlwjhms1DbbQNTsjCsBoKvzSMbh/omSCQ==}
|
||||
|
||||
'@hotwired/turbo@8.0.13':
|
||||
resolution: {integrity: sha512-M7qXUqcGab6G5PKOiwhgbByTtrPgKPFCTMNQ52QhzUEXEqmp0/ApEguUesh/FPiUjrmFec+3lq98KsWnYY2C7g==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
'@humanwhocodes/config-array@0.11.14':
|
||||
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
@@ -1118,6 +1125,9 @@ packages:
|
||||
'@rails/actioncable@6.1.3':
|
||||
resolution: {integrity: sha512-m02524MR9cTnUNfGz39Lkx9jVvuL0tle4O7YgvouJ7H83FILxzG1nQ5jw8pAjLAr9XQGu+P1sY4SKE3zyhCNjw==}
|
||||
|
||||
'@rails/actioncable@7.2.201':
|
||||
resolution: {integrity: sha512-wsTdWoZ5EfG5k3t7ORdyQF0ZmDEgN4aVPCanHAiNEwCROqibSZMXXmCbH7IDJUVri4FOeAVwwbPINI7HVHPKBw==}
|
||||
|
||||
'@rails/ujs@7.1.400':
|
||||
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
|
||||
|
||||
@@ -4368,9 +4378,6 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
turbolinks@5.2.0:
|
||||
resolution: {integrity: sha512-pMiez3tyBo6uRHFNNZoYMmrES/IaGgMhQQM+VFF36keryjb5ms0XkVpmKHkfW/4Vy96qiGW3K9bz0tF5sK9bBw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -5493,6 +5500,13 @@ snapshots:
|
||||
|
||||
'@histoire/vendors@0.17.17': {}
|
||||
|
||||
'@hotwired/turbo-rails@8.0.13':
|
||||
dependencies:
|
||||
'@hotwired/turbo': 8.0.13
|
||||
'@rails/actioncable': 7.2.201
|
||||
|
||||
'@hotwired/turbo@8.0.13': {}
|
||||
|
||||
'@humanwhocodes/config-array@0.11.14':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
@@ -5704,6 +5718,8 @@ snapshots:
|
||||
|
||||
'@rails/actioncable@6.1.3': {}
|
||||
|
||||
'@rails/actioncable@7.2.201': {}
|
||||
|
||||
'@rails/ujs@7.1.400': {}
|
||||
|
||||
'@rollup/plugin-yaml@4.1.2(rollup@4.59.0)':
|
||||
@@ -9373,8 +9389,6 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
turbolinks@5.2.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -140,7 +140,7 @@ describe PortalHelper do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq(
|
||||
'/hc/portal_slug/en'
|
||||
'/hc/portal_slug/en?show_plain_layout=true'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -148,7 +148,7 @@ describe PortalHelper do
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq(
|
||||
'/hc/portal_slug/en?theme=dark'
|
||||
'/hc/portal_slug/en?show_plain_layout=true&theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -172,7 +172,7 @@ describe PortalHelper do
|
||||
theme: nil,
|
||||
is_plain_layout_enabled: true
|
||||
)).to eq(
|
||||
'/hc/portal_slug/en/categories/category_slug'
|
||||
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -186,7 +186,7 @@ describe PortalHelper do
|
||||
theme: 'dark',
|
||||
is_plain_layout_enabled: true
|
||||
)).to eq(
|
||||
'/hc/portal_slug/en/categories/category_slug?theme=dark'
|
||||
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true&theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -210,7 +210,7 @@ describe PortalHelper do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq(
|
||||
'/hc/portal_slug/articles/article_slug'
|
||||
'/hc/portal_slug/articles/article_slug?show_plain_layout=true'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -218,7 +218,7 @@ describe PortalHelper do
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq(
|
||||
'/hc/portal_slug/articles/article_slug?theme=dark'
|
||||
'/hc/portal_slug/articles/article_slug?show_plain_layout=true&theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,6 +20,15 @@ RSpec.describe Article do
|
||||
expect(article).not_to be_valid
|
||||
expect(article.errors[:content]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'rejects reserved slugs that collide with help center routes' do
|
||||
Article::RESERVED_SLUGS.each do |reserved_slug|
|
||||
article = build(:article, portal_id: portal_1.id, author_id: user.id, category_id: category_1.id,
|
||||
title: reserved_slug, slug: reserved_slug, content: 'content')
|
||||
expect(article).not_to be_valid
|
||||
expect(article.errors[:slug]).to include('is reserved')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'associations' do
|
||||
|
||||
Reference in New Issue
Block a user