feat: Search UI

- Search UI with dropdown results
- Client class to handle Product Search actions and results
- Integrated Search bar into all-products and item group pages
- Run db search without redisearch
- Cleanup: [Search] change decorator names and variables
- Sider fixes
This commit is contained in:
marination 2021-06-01 12:44:49 +05:30
parent dcc79f1bfa
commit b0d7e32018
18 changed files with 369 additions and 342 deletions

View File

@ -6,7 +6,7 @@ import frappe
from frappe.utils import cint, comma_and
from frappe import _, msgprint
from frappe.model.document import Document
from frappe.utils import get_datetime, get_datetime_str, now_datetime, unique
from frappe.utils import unique
from erpnext.e_commerce.website_item_indexing import create_website_items_index, ALLOWED_INDEXABLE_FIELDS_SET, is_search_module_loaded
class ShoppingCartSetupError(frappe.ValidationError): pass
@ -59,11 +59,8 @@ class ECommerceSettings(Document):
if not self.search_index_fields:
return
# Clean up
# Remove whitespaces
fields = self.search_index_fields.replace(' ', '')
# Remove extra ',' and remove duplicates
fields = unique(fields.strip(',').split(','))
fields = unique(fields.strip(',').split(',')) # Remove extra ',' and remove duplicates
# All fields should be indexable
if not (set(fields).issubset(ALLOWED_INDEXABLE_FIELDS_SET)):

View File

@ -144,6 +144,8 @@ erpnext.ProductGrid = class {
${ __('Add to Cart') }
</div>
`;
} else {
return ``;
}
}
};

View File

@ -153,6 +153,8 @@ erpnext.ProductList = class {
${ __('Add to Cart') }
</div>
`;
} else {
return ``;
}
}

View File

@ -0,0 +1,226 @@
erpnext.ProductSearch = class {
constructor() {
this.MAX_RECENT_SEARCHES = 4;
this.searchBox = $("#search-box");
this.setupSearchDropDown();
this.bindSearchAction();
}
setupSearchDropDown() {
this.search_area = $("#dropdownMenuSearch");
this.setupSearchResultContainer();
this.setupProductsContainer();
this.setupCategoryRecentsContainer();
this.populateRecentSearches();
}
bindSearchAction() {
let me = this;
this.searchBox.on("focus", (e) => {
this.search_dropdown.removeClass("hidden");
});
this.searchBox.on("focusout", (e) => {
this.search_dropdown.addClass("hidden");
});
this.searchBox.on("input", (e) => {
let query = e.target.value;
if (query.length < 3 || !query.length) return;
// Populate recent search chips
me.setRecentSearches(query);
// Fetch and populate product results
frappe.call({
method: "erpnext.templates.pages.product_search.search",
args: {
query: query
},
callback: (data) => {
me.populateResults(data);
}
});
// Populate categories
if (me.category_container) {
frappe.call({
method: "erpnext.templates.pages.product_search.get_category_suggestions",
args: {
query: query
},
callback: (data) => {
me.populateCategoriesList(data)
}
});
}
this.search_dropdown.removeClass("hidden");
});
}
setupSearchResultContainer() {
this.search_dropdown = this.search_area.append(`
<div class="overflow-hidden shadow dropdown-menu w-100 hidden"
id="search-results-container"
aria-labelledby="dropdownMenuSearch"
style="display: flex;">
</div>
`).find("#search-results-container");
}
setupProductsContainer() {
let $products_section = this.search_dropdown.append(`
<div class="col-7 mr-2 mt-1"
id="product-results"
style="border-right: 1px solid var(--gray-200);">
</div>
`).find("#product-results");
this.products_container = $products_section.append(`
<div id="product-scroll" style="overflow: scroll; max-height: 300px">
<div class="mt-6 w-100 text-muted" style="font-weight: 400; text-align: center;">
${ __("Type something ...") }
</div>
</div>
`).find("#product-scroll");
}
setupCategoryRecentsContainer() {
let $category_recents_section = $("#search-results-container").append(`
<div id="category-recents-container"
class="col-5 mt-2 h-100"
style="margin-left: -15px;">
</div>
`).find("#category-recents-container");
this.category_container = $category_recents_section.append(`
<div class="category-container">
<div class="mb-2"
style="border-bottom: 1px solid var(--gray-200);">
${ __("Categories") }
</div>
<div class="categories">
<span class="text-muted" style="font-weight: 400;"> ${ __('No results') } <span>
</div>
</div>
`).find(".categories");
let $recents_section = $("#category-recents-container").append(`
<div class="mb-2 mt-4 recent-searches">
<div style="border-bottom: 1px solid var(--gray-200);">
${ __("Recent") }
</div>
</div>
`).find(".recent-searches");
this.recents_container = $recents_section.append(`
<div id="recent-chips" style="padding: 1rem 0;">
</div>
`).find("#recent-chips");
}
getRecentSearches() {
return JSON.parse(localStorage.getItem("recent_searches") || "[]");
}
attachEventListenersToChips() {
let me = this;
const chips = $(".recent-chip");
window.chips = chips;
for (let chip of chips) {
chip.addEventListener("click", () => {
me.searchBox[0].value = chip.innerText;
// Start search with `recent query`
me.searchBox.trigger("input");
me.searchBox.focus();
});
}
}
setRecentSearches(query) {
let recents = this.getRecentSearches();
if (recents.length >= this.MAX_RECENT_SEARCHES) {
// Remove the `first` query
recents.splice(0, 1);
}
if (recents.indexOf(query) >= 0) {
return;
}
recents.push(query);
localStorage.setItem("recent_searches", JSON.stringify(recents));
this.populateRecentSearches();
}
populateRecentSearches() {
let recents = this.getRecentSearches();
if (!recents.length) {
return;
}
let html = "";
recents.forEach((key) => {
html += `<button class="btn btn-sm recent-chip mr-1 mb-2">${ key }</button>`;
});
this.recents_container.html(html);
this.attachEventListenersToChips();
}
populateResults(data) {
if (data.message.results.length === 0) {
this.products_container.html('No results');
return;
}
let html = "";
let search_results = data.message.results;
search_results.forEach((res) => {
html += `
<div class="dropdown-item" style="display: flex;">
<img class="item-thumb col-2" src=${res.thumbnail || 'img/placeholder.png'} />
<div class="col-9" style="white-space: normal;">
<a href="/${res.route}">${res.web_item_name}</a><br>
<span class="brand-line">${res.brand ? "by " + res.brand : ""}</span>
</div>
</div>
`;
});
this.products_container.html(html);
}
populateCategoriesList(data) {
if (data.message.results.length === 0) {
let empty_html = `
<span class="text-muted" style="font-weight: 400;">
${__('No results')}
</span>
`;
this.category_container.html(empty_html);
return;
}
let html = ""
let search_results = data.message.results
search_results.forEach((category) => {
html += `
<div class="mb-2" style="font-weight: 400;">
<a href="/${category.route}">${category.name}</a>
</div>
`;
})
this.category_container.html(html);
}
}

View File

@ -402,4 +402,4 @@ erpnext.ProductView = class {
}
return exists ? obj : undefined;
}
}
};

View File

@ -1,39 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils.redis_wrapper import RedisWrapper
from redisearch import (Client, AutoCompleter, Suggestion, IndexDefinition, TextField, TagField)
from redisearch import (
Client, AutoCompleter,
Suggestion, IndexDefinition,
TextField, TagField
)
def is_search_module_loaded():
cache = frappe.cache()
out = cache.execute_command('MODULE LIST')
parsed_output = " ".join(
(" ".join([s.decode() for s in o if not isinstance(s, int)]) for o in out)
)
return "search" in parsed_output
# Decorator for checking wether Redisearch is there or not
def redisearch_decorator(function):
def wrapper(*args, **kwargs):
if is_search_module_loaded():
func = function(*args, **kwargs)
return func
return
return wrapper
def make_key(key):
return "{0}|{1}".format(frappe.conf.db_name, key).encode('utf-8')
# GLOBAL CONSTANTS
WEBSITE_ITEM_INDEX = 'website_items_index'
WEBSITE_ITEM_KEY_PREFIX = 'website_item:'
WEBSITE_ITEM_NAME_AUTOCOMPLETE = 'website_items_name_dict'
@ -48,7 +19,30 @@ ALLOWED_INDEXABLE_FIELDS_SET = {
'web_long_description'
}
@redisearch_decorator
def is_search_module_loaded():
cache = frappe.cache()
out = cache.execute_command('MODULE LIST')
parsed_output = " ".join(
(" ".join([s.decode() for s in o if not isinstance(s, int)]) for o in out)
)
return "search" in parsed_output
# Decorator for checking wether Redisearch is there or not
def if_redisearch_loaded(function):
def wrapper(*args, **kwargs):
if is_search_module_loaded():
func = function(*args, **kwargs)
return func
return
return wrapper
def make_key(key):
return "{0}|{1}".format(frappe.conf.db_name, key).encode('utf-8')
@if_redisearch_loaded
def create_website_items_index():
'''Creates Index Definition'''
# CREATE index
@ -57,21 +51,20 @@ def create_website_items_index():
# DROP if already exists
try:
client.drop_index()
except:
except Exception:
pass
idx_def = IndexDefinition([make_key(WEBSITE_ITEM_KEY_PREFIX)])
# Based on e-commerce settings
idx_fields = frappe.db.get_single_value(
'E Commerce Settings',
'E Commerce Settings',
'search_index_fields'
).split(',')
if 'web_item_name' in idx_fields:
idx_fields.remove('web_item_name')
idx_fields = list(map(to_search_field, idx_fields))
client.create_index(
@ -88,26 +81,25 @@ def to_search_field(field):
return TextField(field)
@redisearch_decorator
@if_redisearch_loaded
def insert_item_to_index(website_item_doc):
# Insert item to index
key = get_cache_key(website_item_doc.name)
r = frappe.cache()
cache = frappe.cache()
web_item = create_web_item_map(website_item_doc)
for k, v in web_item.items():
super(RedisWrapper, r).hset(make_key(key), k, v)
super(RedisWrapper, cache).hset(make_key(key), k, v)
insert_to_name_ac(website_item_doc.web_item_name, website_item_doc.name)
@redisearch_decorator
@if_redisearch_loaded
def insert_to_name_ac(web_name, doc_name):
ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=frappe.cache())
ac.add_suggestions(Suggestion(web_name, payload=doc_name))
def create_web_item_map(website_item_doc):
fields_to_index = get_fields_indexed()
web_item = {}
for f in fields_to_index:
@ -115,59 +107,58 @@ def create_web_item_map(website_item_doc):
return web_item
@redisearch_decorator
@if_redisearch_loaded
def update_index_for_item(website_item_doc):
# Reinsert to Cache
insert_item_to_index(website_item_doc)
define_autocomplete_dictionary()
@redisearch_decorator
@if_redisearch_loaded
def delete_item_from_index(website_item_doc):
r = frappe.cache()
cache = frappe.cache()
key = get_cache_key(website_item_doc.name)
try:
r.delete(key)
cache.delete(key)
except:
return False
delete_from_ac_dict(website_item_doc)
return True
@redisearch_decorator
@if_redisearch_loaded
def delete_from_ac_dict(website_item_doc):
'''Removes this items's name from autocomplete dictionary'''
r = frappe.cache()
name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=r)
cache = frappe.cache()
name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache)
name_ac.delete(website_item_doc.web_item_name)
@redisearch_decorator
@if_redisearch_loaded
def define_autocomplete_dictionary():
"""Creates an autocomplete search dictionary for `name`.
Also creats autocomplete dictionary for `categories` if
checked in E Commerce Settings"""
Also creats autocomplete dictionary for `categories` if
checked in E Commerce Settings"""
r = frappe.cache()
name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=r)
cat_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=r)
cache = frappe.cache()
name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache)
cat_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=cache)
ac_categories = frappe.db.get_single_value(
'E Commerce Settings',
'E Commerce Settings',
'show_categories_in_search_autocomplete'
)
# Delete both autocomplete dicts
try:
r.delete(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE))
r.delete(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE))
cache.delete(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE))
cache.delete(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE))
except:
return False
items = frappe.get_all(
'Website Item',
fields=['web_item_name', 'item_group'],
filters={"published": True}
'Website Item',
fields=['web_item_name', 'item_group'],
filters={"published": 1}
)
for item in items:
@ -177,21 +168,21 @@ def define_autocomplete_dictionary():
return True
@redisearch_decorator
@if_redisearch_loaded
def reindex_all_web_items():
items = frappe.get_all(
'Website Item',
fields=get_fields_indexed(),
'Website Item',
fields=get_fields_indexed(),
filters={"published": True}
)
r = frappe.cache()
cache = frappe.cache()
for item in items:
web_item = create_web_item_map(item)
key = make_key(get_cache_key(item.name))
for k, v in web_item.items():
super(RedisWrapper, r).hset(key, k, v)
super(RedisWrapper, cache).hset(key, k, v)
def get_cache_key(name):
name = frappe.scrub(name)
@ -199,7 +190,7 @@ def get_cache_key(name):
def get_fields_indexed():
fields_to_index = frappe.db.get_single_value(
'E Commerce Settings',
'E Commerce Settings',
'search_index_fields'
).split(',')

View File

@ -70,6 +70,7 @@
"js/e-commerce.min.js": [
"e_commerce/product_view.js",
"e_commerce/product_grid.js",
"e_commerce/product_list.js"
"e_commerce/product_list.js",
"e_commerce/product_search.js"
]
}

View File

@ -199,7 +199,7 @@ $.extend(shopping_cart, {
},
freeze() {
if (window.location.pathname !== "/cart") return
if (window.location.pathname !== "/cart") return;
if (!$('#freeze').length) {
let freeze = $('<div id="freeze" class="modal-backdrop fade"></div>')

View File

@ -421,7 +421,7 @@ body.product-page {
.total-discount {
font-size: var(--text-base);
color: var(--primary-color);
color: var(--primary-color) !important;
}
#page-cart {
@ -529,6 +529,7 @@ body.product-page {
height: 22px;
background-color: var(--gray-200);
float: right;
cursor: pointer;
}
.remove-cart-item-logo {
@ -865,3 +866,14 @@ body.product-page {
background-color: var(--gray-100);
height: 100%;
}
.item-thumb {
height: 50px;
max-width: 80px;
min-width: 80px;
object-fit: cover;
}
.brand-line {
color: gray;
}

View File

@ -2,7 +2,16 @@
{% extends "templates/web.html" %}
{% block header %}
<!-- <h2>{{ title }}</h2> -->
<div class="row mb-6" style="width: 65vw">
<div class="mb-6 col-4 order-1">{{ title }}</div>
<div class="input-group mb-6 col-8 order-2">
<div class="dropdown w-100" id="dropdownMenuSearch">
<input type="search" name="query" id="search-box" class="form-control" placeholder="Search for products..." aria-label="Product" aria-describedby="button-addon2">
<!-- Results dropdown rendered in product_search.js -->
</div>
</div>
</div>
{% endblock header %}
{% block script %}
@ -19,7 +28,7 @@
<div class="item-group-content" itemscope itemtype="http://schema.org/Product"
data-item-group="{{ name }}">
<div class="item-group-slideshow">
{% if slideshow %}<!-- slideshow -->
{% if slideshow %} <!-- slideshow -->
{{ web_block(
"Hero Slider",
values=slideshow,
@ -28,8 +37,8 @@
add_bottom_padding=0,
) }}
{% endif %}
<h2 class="mt-3">{{ title }}</h2>
{% if description %}<!-- description -->
{% if description %} <!-- description -->
<div class="item-group-description text-muted mb-5" itemprop="description">{{ description or ""}}</div>
{% endif %}
</div>

View File

@ -163,7 +163,7 @@ $.extend(shopping_cart, {
item_code: item_code,
qty: 0
});
})
});
},
render_tax_row: function($cart_taxes, doc, shipping_rules) {

View File

@ -7,7 +7,6 @@ from frappe.utils import cint, cstr, nowdate
from erpnext.setup.doctype.item_group.item_group import get_item_for_list_in_html
from erpnext.e_commerce.shopping_cart.product_info import set_product_info_for_website
# For SEARCH -------
from redisearch import AutoCompleter, Client, Query
from erpnext.e_commerce.website_item_indexing import (
is_search_module_loaded,
@ -16,7 +15,6 @@ from erpnext.e_commerce.website_item_indexing import (
WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE,
make_key
)
# -----------------
no_cache = 1
@ -36,30 +34,29 @@ def get_product_list(search=None, start=0, limit=12):
def get_product_data(search=None, start=0, limit=12):
# limit = 12 because we show 12 items in the grid view
# base query
query = """select I.name, I.item_name, I.item_code, I.route, I.image, I.website_image, I.thumbnail, I.item_group,
I.description, I.web_long_description as website_description, I.is_stock_item,
case when (S.actual_qty - S.reserved_qty) > 0 then 1 else 0 end as in_stock, I.website_warehouse,
I.has_batch_no
from `tabItem` I
left join tabBin S on I.item_code = S.item_code and I.website_warehouse = S.warehouse
where (I.show_in_website = 1)
and I.disabled = 0
and (I.end_of_life is null or I.end_of_life='0000-00-00' or I.end_of_life > %(today)s)"""
query = """
Select
web_item_name, item_name, item_code, brand, route,
website_image, thumbnail, item_group,
description, web_long_description as website_description,
website_warehouse, ranking
from `tabWebsite Item`
where published = 1
"""
# search term condition
if search:
query += """ and (I.web_long_description like %(search)s
or I.description like %(search)s
or I.item_name like %(search)s
or I.name like %(search)s)"""
query += """ and (item_name like %(search)s
or web_item_name like %(search)s
or brand like %(search)s
or web_long_description like %(search)s)"""
search = "%" + cstr(search) + "%"
# order by
query += """ order by I.weightage desc, in_stock desc, I.modified desc limit %s, %s""" % (cint(start), cint(limit))
query += """ order by ranking asc, modified desc limit %s, %s""" % (cint(start), cint(limit))
return frappe.db.sql(query, {
"search": search,
"today": nowdate()
"search": search
}, as_dict=1)
@frappe.whitelist(allow_guest=True)
@ -112,11 +109,19 @@ def convert_to_dict(redis_search_doc):
@frappe.whitelist(allow_guest=True)
def get_category_suggestions(query):
search_results = {"from_redisearch": True, "results": []}
search_results = {"results": []}
if not is_search_module_loaded():
# Redisearch module not loaded
search_results["from_redisearch"] = False
# Redisearch module not loaded, query db
categories = frappe.db.get_all(
"Item Group",
filters={
"name": ["like", "%{0}%".format(query)],
"show_in_website": 1
},
fields=["name", "route"]
)
search_results['results'] = categories
return search_results
if not query:

View File

@ -3,35 +3,19 @@
{% block title %}{{ _('Products') }}{% endblock %}
{% block header %}
<div class="mb-6">{{ _('Products') }}</div>
<div class="row mb-6" style="width: 65vw">
<div class="mb-6 col-4 order-1">{{ _('Products') }}</div>
<div class="input-group mb-6 col-8 order-2">
<div class="dropdown w-100" id="dropdownMenuSearch">
<input type="search" name="query" id="search-box" class="form-control" placeholder="Search for products..." aria-label="Product" aria-describedby="button-addon2">
<!-- Results dropdown rendered in product_search.js -->
</div>
</div>
</div>
{% endblock header %}
{% block page_content %}
<!-- Old Search -->
<!-- <div class="row" style="display: none;">
<div class="col-8">
<div class="input-group input-group-sm mb-3">
<input type="search" class="form-control" placeholder="{{_('Search')}}"
aria-label="{{_('Product Search')}}" aria-describedby="product-search"
value="{{ frappe.sanitize_html(frappe.form_dict.search) or '' }}"
>
</div>
</div>
<div class="col-4 pl-0">
<button class="btn btn-light btn-sm btn-block d-md-none"
type="button"
data-toggle="collapse"
data-target="#product-filters"
aria-expanded="false"
aria-controls="product-filters"
style="white-space: nowrap;"
>
{{ _('Toggle Filters') }}
</button>
</div>
</div> -->
<div class="row">
<!-- Items section -->
<div id="product-listing" class="col-12 order-2 col-md-9 order-md-2 item-card-group-section">
@ -40,11 +24,6 @@
<!-- Filters Section -->
<div class="col-12 order-1 col-md-3 order-md-1">
{% if frappe.form_dict.start or frappe.form_dict.field_filters or frappe.form_dict.attribute_filters or frappe.form_dict.search %}
{% endif %}
<div class="collapse d-md-block mr-4 filters-section" id="product-filters">
<div class="d-flex justify-content-between align-items-center mb-5 title-section">
<div class="mb-4 filters-title" > {{ _('Filters') }} </div>

View File

@ -7,8 +7,10 @@ $(() => {
let view_type = "List View";
// Render Product Views and setup Filters
// Render Product Views, Filters & Search
frappe.require('/assets/js/e-commerce.min.js', function() {
new erpnext.ProductSearch();
new erpnext.ProductView({
view_type: view_type,
products_section: $('#product-listing'),

View File

@ -1,9 +0,0 @@
.item-thumb {
height: 50px;
width: 50px;
object-fit: cover;
}
.brand-line {
color: gray;
}

View File

@ -1,46 +0,0 @@
{% extends "templates/web.html" %}
{% block title %}{{ _('Search') }}{% endblock %}
{%- block head_include %}
<link rel="stylesheet" href="search.css">
{% endblock -%}
{% block header %}
<div class="mb-6">{{ _('Search Products') }}</div>
{% endblock header %}
{% block page_content %}
<div class="input-group mb-3">
<input type="text" name="query" id="search-box" class="form-control" placeholder="Search for products..." aria-label="Product" aria-describedby="button-addon2">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="search-button">Search</button>
</div>
</div>
<!-- To show recent searches -->
<div class="my-2" id="recent-search-chips"></div>
<div class="row mt-2">
<!-- Search Results -->
<div class="col-sm">
<h2>Products</h2>
<ul id="results" class="list-group"></ul>
</div>
{% set show_categories = frappe.db.get_single_value('E Commerce Settings', 'show_categories_in_search_autocomplete') %}
{% if show_categories %}
<div id="categories" class="col-sm">
<h2>Categories</h2>
<ul id="category-suggestions">
</ul>
</div>
{% endif %}
{% set show_brand_line = frappe.db.get_single_value('E Commerce Settings', 'show_brand_line') %}
{% if show_brand_line %}
<span id="show-brand-line"></span>
{% endif %}
</div>
{% endblock %}

View File

@ -1,148 +0,0 @@
let loading = false;
const MAX_RECENT_SEARCHES = 4;
const searchBox = document.getElementById("search-box");
const searchButton = document.getElementById("search-button");
const results = document.getElementById("results");
const categoryList = document.getElementById("category-suggestions");
const showBrandLine = document.getElementById("show-brand-line");
const recentSearchArea = document.getElementById("recent-search-chips");
function getRecentSearches() {
return JSON.parse(localStorage.getItem("recent_searches") || "[]");
}
function attachEventListenersToChips() {
const chips = document.getElementsByClassName("recent-chip");
for (let chip of chips) {
chip.addEventListener("click", () => {
searchBox.value = chip.innerText;
// Start search with `recent query`
const event = new Event("input");
searchBox.dispatchEvent(event);
searchBox.focus();
});
}
}
function populateRecentSearches() {
let recents = getRecentSearches();
if (!recents.length) {
return;
}
html = "Recent Searches: ";
for (let query of recents) {
html += `<button class="btn btn-secondary btn-sm recent-chip mr-1">${query}</button>`;
}
recentSearchArea.innerHTML = html;
attachEventListenersToChips();
}
function populateResults(data) {
if (!data.message.from_redisearch) {
// Data not from redisearch
}
if (data.message.results.length === 0) {
results.innerHTML = 'No results';
return;
}
html = ""
search_results = data.message.results
for (let res of search_results) {
html += `<li class="list-group-item list-group-item-action">
<img class="item-thumb" src="${res.thumbnail || 'img/placeholder.png'}" />
<a href="/${res.route}">${res.web_item_name} <span class="brand-line">${showBrandLine && res.brand ? "by " + res.brand : ""}</span></a>
</li>`
}
results.innerHTML = html;
}
function populateCategoriesList(data) {
if (!data.message.from_redisearch) {
// Data not from redisearch
categoryList.innerHTML = "Install Redisearch to enable autocompletions.";
return;
}
if (data.message.results.length === 0) {
categoryList.innerHTML = 'No results';
return;
}
html = ""
search_results = data.message.results
for (let category of search_results) {
html += `<li>${category}</li>`
}
categoryList.innerHTML = html;
}
function updateLoadingState() {
if (loading) {
results.innerHTML = `<div class="spinner-border"><span class="sr-only">loading...<span></div>`;
}
}
searchBox.addEventListener("input", (e) => {
loading = true;
updateLoadingState();
frappe.call({
method: "erpnext.templates.pages.product_search.search",
args: {
query: e.target.value
},
callback: (data) => {
populateResults(data);
loading = false;
}
});
// If there is a suggestion list node
if (categoryList) {
frappe.call({
method: "erpnext.templates.pages.product_search.get_category_suggestions",
args: {
query: e.target.value
},
callback: (data) => {
populateCategoriesList(data);
}
});
}
});
searchButton.addEventListener("click", (e) => {
let query = searchBox.value;
if (!query) {
return;
}
let recents = getRecentSearches();
if (recents.length >= MAX_RECENT_SEARCHES) {
// Remove the `First` query
recents.splice(0, 1);
}
if (recents.indexOf(query) >= 0) {
return;
}
recents.push(query);
localStorage.setItem("recent_searches", JSON.stringify(recents));
// Refresh recent searches
populateRecentSearches();
});
populateRecentSearches();

View File

@ -71,8 +71,12 @@ def get_category_records(categories):
fields += ["image"]
categorical_data[category] = frappe.db.sql(f"""
Select {",".join(fields)}
from `tab{doctype}`""", as_dict=1)
Select
{",".join(fields)}
from
`tab{doctype}`""",
as_dict=1
)
return categorical_data