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:
parent
dcc79f1bfa
commit
b0d7e32018
@ -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)):
|
||||
|
@ -144,6 +144,8 @@ erpnext.ProductGrid = class {
|
||||
${ __('Add to Cart') }
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
};
|
@ -153,6 +153,8 @@ erpnext.ProductList = class {
|
||||
${ __('Add to Cart') }
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return ``;
|
||||
}
|
||||
}
|
||||
|
||||
|
226
erpnext/e_commerce/product_search.js
Normal file
226
erpnext/e_commerce/product_search.js
Normal 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);
|
||||
}
|
||||
}
|
@ -402,4 +402,4 @@ erpnext.ProductView = class {
|
||||
}
|
||||
return exists ? obj : undefined;
|
||||
}
|
||||
}
|
||||
};
|
@ -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(',')
|
||||
|
||||
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
@ -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>')
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -163,7 +163,7 @@ $.extend(shopping_cart, {
|
||||
item_code: item_code,
|
||||
qty: 0
|
||||
});
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
render_tax_row: function($cart_taxes, doc, shipping_rules) {
|
||||
|
@ -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:
|
||||
|
@ -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>
|
||||
|
@ -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'),
|
||||
|
@ -1,9 +0,0 @@
|
||||
.item-thumb {
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.brand-line {
|
||||
color: gray;
|
||||
}
|
@ -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 %}
|
@ -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();
|
@ -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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user