search in admin also
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set business = audience == "business" %}
|
{% set business = audience == "business" %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
{% set q_enc = query | default(value='') | urlencode %}
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
||||||
@@ -15,20 +17,34 @@
|
|||||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- audience tabs -->
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="mt-4 inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
<!-- audience tabs -->
|
||||||
<a href="/admin/catalog/products?audience=personal"
|
<div class="inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
||||||
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
<a href="/admin/catalog/products?audience=personal&q={{ q_enc }}"
|
||||||
{{ t(key="audience-personal", lang=lang | default(value='sk')) }}
|
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||||
</a>
|
{{ t(key="audience-personal", lang=L) }}
|
||||||
<a href="/admin/catalog/products?audience=business"
|
</a>
|
||||||
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
<a href="/admin/catalog/products?audience=business&q={{ q_enc }}"
|
||||||
{{ t(key="audience-business", lang=lang | default(value='sk')) }}
|
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||||
</a>
|
{{ t(key="audience-business", lang=L) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- product search (drafts included); keeps the active audience + category -->
|
||||||
|
<form method="get" action="/admin/catalog/products" role="search" class="relative w-full max-w-xs">
|
||||||
|
<input type="hidden" name="audience" value="{{ audience }}">
|
||||||
|
<input type="hidden" name="category" value="{{ selected_category }}">
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=L) }}" aria-label="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% set category_base = "/admin/catalog/products" %}
|
{% set category_base = "/admin/catalog/products" %}
|
||||||
{% set category_suffix = "&audience=" ~ audience %}
|
{% set category_suffix = "&audience=" ~ audience ~ "&q=" ~ q_enc %}
|
||||||
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
|
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
{% include "admin/partials/category_filter.html" %}
|
{% include "admin/partials/category_filter.html" %}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
{% set q_enc = query | default(value='') | urlencode %}
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</h1>
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</h1>
|
||||||
@@ -41,10 +43,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}</p>
|
<div class="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=L) }}</p>
|
||||||
|
|
||||||
|
<!-- product search (drafts included); keeps the active category -->
|
||||||
|
<form method="get" action="/admin/customers/{{ customer.id }}" role="search" class="relative w-full max-w-xs">
|
||||||
|
<input type="hidden" name="category" value="{{ selected_category }}">
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=L) }}" aria-label="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% set category_base = "/admin/customers/" ~ customer.id %}
|
{% set category_base = "/admin/customers/" ~ customer.id %}
|
||||||
{% set category_suffix = "" %}
|
{% set category_suffix = "&q=" ~ q_enc %}
|
||||||
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
|
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
{% include "admin/partials/category_filter.html" %}
|
{% include "admin/partials/category_filter.html" %}
|
||||||
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
|
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
|
||||||
|
|||||||
@@ -138,10 +138,17 @@ async fn show(
|
|||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let list = products::Entity::find()
|
// Optional text search (drafts included), otherwise the whole catalog by
|
||||||
.order_by_asc(products::Column::Name)
|
// name. Reuses the storefront's hybrid full-text + fuzzy product search.
|
||||||
.all(&ctx.db)
|
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||||
.await?;
|
let list = if query.is_empty() {
|
||||||
|
products::Entity::find()
|
||||||
|
.order_by_asc(products::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
products::Entity::search(&ctx.db, &query, 1000, false).await?
|
||||||
|
};
|
||||||
|
|
||||||
// Category sidebar tree (counts over the full, unfiltered product list) plus
|
// Category sidebar tree (counts over the full, unfiltered product list) plus
|
||||||
// the active `?category=` filter applied to the rows.
|
// the active `?category=` filter applied to the rows.
|
||||||
@@ -212,6 +219,7 @@ async fn show(
|
|||||||
"products": rows,
|
"products": rows,
|
||||||
"category_groups": category_groups,
|
"category_groups": category_groups,
|
||||||
"selected_category": selected_category,
|
"selected_category": selected_category,
|
||||||
|
"query": query,
|
||||||
"total_count": list.len(),
|
"total_count": list.len(),
|
||||||
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||||
"error": params.get("error"),
|
"error": params.get("error"),
|
||||||
|
|||||||
@@ -281,10 +281,17 @@ async fn index(
|
|||||||
.map(|c| (c.id, c.name.clone()))
|
.map(|c| (c.id, c.name.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let list = products::Entity::find()
|
// Optional text search (drafts included), otherwise the full catalog newest
|
||||||
.order_by_desc(products::Column::CreatedAt)
|
// first. Reuses the storefront's hybrid full-text + fuzzy product search.
|
||||||
.all(&ctx.db)
|
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||||
.await?;
|
let list = if query.is_empty() {
|
||||||
|
products::Entity::find()
|
||||||
|
.order_by_desc(products::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
products::Entity::search(&ctx.db, &query, 1000, false).await?
|
||||||
|
};
|
||||||
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||||
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
||||||
|
|
||||||
@@ -352,6 +359,7 @@ async fn index(
|
|||||||
"audience": audience,
|
"audience": audience,
|
||||||
"category_groups": category_groups,
|
"category_groups": category_groups,
|
||||||
"selected_category": selected_category,
|
"selected_category": selected_category,
|
||||||
|
"query": query,
|
||||||
"total_count": list.len(),
|
"total_count": list.len(),
|
||||||
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ async fn run_search(
|
|||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
products::Entity::search(&ctx.db, &q_trim, SEARCH_CAP).await?
|
products::Entity::search(&ctx.db, &q_trim, SEARCH_CAP, true).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Attach representative variant + resolved price to each (drop products
|
// 2. Attach representative variant + resolved price to each (drop products
|
||||||
|
|||||||
@@ -37,10 +37,13 @@ impl Entity {
|
|||||||
/// `f_unaccent`, so diacritics never matter. Results are ranked by full-text
|
/// `f_unaccent`, so diacritics never matter. Results are ranked by full-text
|
||||||
/// rank, then trigram closeness of the name, then recency. An empty/blank
|
/// rank, then trigram closeness of the name, then recency. An empty/blank
|
||||||
/// query returns nothing — callers fall back to the plain listing.
|
/// query returns nothing — callers fall back to the plain listing.
|
||||||
|
/// `published_only` filters to the storefront-visible set; pass `false` for
|
||||||
|
/// admin tools that also need to find drafts.
|
||||||
pub async fn search<C: ConnectionTrait>(
|
pub async fn search<C: ConnectionTrait>(
|
||||||
db: &C,
|
db: &C,
|
||||||
query: &str,
|
query: &str,
|
||||||
limit: u64,
|
limit: u64,
|
||||||
|
published_only: bool,
|
||||||
) -> Result<Vec<Model>, DbErr> {
|
) -> Result<Vec<Model>, DbErr> {
|
||||||
let q = query.trim();
|
let q = query.trim();
|
||||||
if q.is_empty() {
|
if q.is_empty() {
|
||||||
@@ -50,12 +53,13 @@ impl Entity {
|
|||||||
// Only the model's own columns are selected; the generated `search_vector`
|
// Only the model's own columns are selected; the generated `search_vector`
|
||||||
// is left out so the row maps cleanly back onto `Model`. `$1` is reused
|
// is left out so the row maps cleanly back onto `Model`. `$1` is reused
|
||||||
// for every occurrence of the query term; `$2` caps the result set.
|
// for every occurrence of the query term; `$2` caps the result set.
|
||||||
let sql = r#"
|
let published_clause = if published_only { "p.published = TRUE AND" } else { "" };
|
||||||
|
let sql = format!(
|
||||||
|
r#"
|
||||||
SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description,
|
SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description,
|
||||||
p.currency, p.view_count, p.published, p.published_at, p.category_id
|
p.currency, p.view_count, p.published, p.published_at, p.category_id
|
||||||
FROM products p
|
FROM products p
|
||||||
WHERE p.published = TRUE
|
WHERE {published_clause} (
|
||||||
AND (
|
|
||||||
p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1)
|
p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1)
|
||||||
OR word_similarity(f_unaccent($1), f_unaccent(p.name)) > 0.3
|
OR word_similarity(f_unaccent($1), f_unaccent(p.name)) > 0.3
|
||||||
OR word_similarity(f_unaccent($1), f_unaccent(COALESCE(p.description, ''))) > 0.3
|
OR word_similarity(f_unaccent($1), f_unaccent(COALESCE(p.description, ''))) > 0.3
|
||||||
@@ -65,12 +69,13 @@ impl Entity {
|
|||||||
word_similarity(f_unaccent($1), f_unaccent(p.name)) DESC,
|
word_similarity(f_unaccent($1), f_unaccent(p.name)) DESC,
|
||||||
p.published_at DESC NULLS LAST
|
p.published_at DESC NULLS LAST
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
"#;
|
"#
|
||||||
|
);
|
||||||
|
|
||||||
Entity::find()
|
Entity::find()
|
||||||
.from_raw_sql(Statement::from_sql_and_values(
|
.from_raw_sql(Statement::from_sql_and_values(
|
||||||
db.get_database_backend(),
|
db.get_database_backend(),
|
||||||
sql,
|
&sql,
|
||||||
[q.into(), (limit as i64).into()],
|
[q.into(), (limit as i64).into()],
|
||||||
))
|
))
|
||||||
.all(db)
|
.all(db)
|
||||||
|
|||||||
Reference in New Issue
Block a user