[FIX] website_sale_aplicoop: Arreglar búsqueda y filtrado por tags

Problemas resueltos:
- Contador de badges mostraba solo productos de página actual (20) en lugar del total
- Productos cargados con lazy loading no se filtraban por tags seleccionados

Cambios en realtime_search.js:
- Eliminado recálculo dinámico de contadores en _filterProducts()
- Los contadores permanecen estáticos (calculados por backend sobre dataset completo)
- Mejorado logging para debug de tags seleccionados

Cambios en infinite_scroll.js:
- Después de cargar nueva página, actualiza lista de productos para realtime search
- Aplica filtros activos automáticamente a productos recién cargados
- Garantiza consistencia de estado de filtrado en toda la aplicación

Documentación:
- Añadido docs/TAG_FILTER_FIX.md con explicación completa del sistema
- Incluye arquitectura, flujo de datos y casos de prueba
This commit is contained in:
snt 2026-02-18 18:51:26 +01:00
parent fee8ec9c45
commit 19eb1b91b5
3 changed files with 494 additions and 24 deletions

470
docs/TAG_FILTER_FIX.md Normal file
View file

@ -0,0 +1,470 @@
# Arreglo de Búsqueda y Filtrado por Tags
**Fecha**: 18 de febrero de 2026
**Versión**: 18.0.1.3.2
**Addon**: `website_sale_aplicoop`
---
## 📋 Problemas Identificados
### 1. Contador de Badge Incorrecto
**Problema**: El número dentro del badge de tags mostraba solo el total de productos de la primera página (20 con lazy loading), no el total de productos con ese tag en todo el dataset.
**Causa**: El JavaScript recalculaba dinámicamente los contadores en `_filterProducts()` usando `self.allProducts`, que con lazy loading solo contiene los productos de la página actual cargada.
### 2. Filtrado por Tag (Ya Funcionaba Correctamente)
**Estado**: El filtrado por tags ya estaba funcionando correctamente. Al hacer clic en un tag:
- Se añade/remueve del `selectedTags` Set
- Se aplica filtro OR: productos con AL MENOS UN tag seleccionado se muestran
- Los productos sin tags seleccionados se ocultan con clase `.hidden-product`
---
## 🔧 Solución Implementada
### Cambio en `realtime_search.js`
**Archivo**: `/home/snt/Documentos/lab/odoo/addons-cm/website_sale_aplicoop/static/src/js/realtime_search.js`
**Antes (líneas 609-656)**:
```javascript
var visibleCount = 0;
var hiddenCount = 0;
// Track tag counts for dynamic badge updates
var tagCounts = {};
for (var tagId in self.availableTags) {
tagCounts[tagId] = 0;
}
self.allProducts.forEach(function (product) {
// ... filtrado ...
if (shouldShow) {
product.element.classList.remove("hidden-product");
visibleCount++;
// Count this product's tags toward the dynamic counters
product.tags.forEach(function (tagId) {
if (tagCounts.hasOwnProperty(tagId)) {
tagCounts[tagId]++;
}
});
} else {
product.element.classList.add("hidden-product");
hiddenCount++;
}
});
// Update badge counts dynamically
for (var tagId in tagCounts) {
var badge = document.querySelector('[data-tag-id="' + tagId + '"]');
if (badge) {
var countSpan = badge.querySelector(".tag-count");
if (countSpan) {
countSpan.textContent = tagCounts[tagId];
}
}
}
```
**Después**:
```javascript
var visibleCount = 0;
var hiddenCount = 0;
// NOTE: Tag counts are NOT updated dynamically here because with lazy loading,
// self.allProducts only contains products from current page.
// Tag counts must remain as provided by backend (calculated on full dataset).
self.allProducts.forEach(function (product) {
var nameMatches = !searchQuery || product.name.indexOf(searchQuery) !== -1;
var categoryMatches =
!selectedCategoryId || allowedCategories[product.category];
// Tag filtering: if tags are selected, product must have AT LEAST ONE selected tag (OR logic)
var tagMatches = true;
if (self.selectedTags.size > 0) {
tagMatches = product.tags.some(function (productTagId) {
return self.selectedTags.has(productTagId);
});
}
var shouldShow = nameMatches && categoryMatches && tagMatches;
if (shouldShow) {
product.element.classList.remove("hidden-product");
visibleCount++;
} else {
product.element.classList.add("hidden-product");
hiddenCount++;
}
});
```
**Cambios**:
1. ✅ **Eliminado** recálculo dinámico de `tagCounts`
2. ✅ **Eliminado** actualización de `.tag-count` en badges
3. ✅ **Añadido** comentario explicativo sobre por qué no recalcular
4. ✅ **Mejorado** log de debug para incluir tags seleccionados
---
## 🏗️ Arquitectura del Sistema de Tags
### Backend: Cálculo de Contadores (Correcto)
**Archivo**: `controllers/website_sale.py` (líneas 964-990)
```python
# ===== Calculate available tags BEFORE pagination (on complete filtered set) =====
available_tags_dict = {}
for product in filtered_products: # filtered_products = lista completa, no paginada
for tag in product.product_tag_ids:
# Only include tags that are visible on ecommerce
is_visible = getattr(tag, "visible_on_ecommerce", True)
if not is_visible:
continue
if tag.id not in available_tags_dict:
tag_color = tag.color if tag.color else None
available_tags_dict[tag.id] = {
"id": tag.id,
"name": tag.name,
"color": tag_color,
"count": 0,
}
available_tags_dict[tag.id]["count"] += 1
# Convert to sorted list of tags (sorted by name for consistent display)
available_tags = sorted(available_tags_dict.values(), key=lambda t: t["name"])
```
**Características**:
- ✅ Calcula sobre `filtered_products` (lista completa sin paginar)
- ✅ Excluye tags con `visible_on_ecommerce=False`
- ✅ Ordena por nombre
- ✅ Pasa al template vía contexto
### Frontend: Inicialización (Correcto)
**Método**: `_initializeAvailableTags()` (líneas 547-564)
```javascript
_initializeAvailableTags: function () {
var self = this;
var tagBadges = document.querySelectorAll('[data-toggle="tag-filter"]');
tagBadges.forEach(function (badge) {
var tagId = parseInt(badge.getAttribute("data-tag-id"), 10);
var tagName = badge.getAttribute("data-tag-name") || "";
var countSpan = badge.querySelector(".tag-count");
var count = countSpan ? parseInt(countSpan.textContent, 10) : 0;
self.availableTags[tagId] = {
id: tagId,
name: tagName,
count: count, // ✅ Leído del DOM (viene del backend)
};
});
}
```
**Características**:
- ✅ Lee contadores iniciales del DOM (generados por backend)
- ✅ No recalcula nunca
- ✅ Mantiene referencia para saber qué tags existen
### Frontend: Filtrado (Corregido)
**Método**: `_filterProducts()` (líneas 566-668)
**Lógica de filtrado**:
```javascript
// Tag filtering: if tags are selected, product must have AT LEAST ONE selected tag (OR logic)
var tagMatches = true;
if (self.selectedTags.size > 0) {
tagMatches = product.tags.some(function (productTagId) {
return self.selectedTags.has(productTagId);
});
}
```
**Comportamiento**:
- Si `selectedTags` vacío → todos los productos pasan
- Si `selectedTags` tiene 1+ elementos → solo productos con AL MENOS UN tag seleccionado pasan
- Lógica OR entre tags seleccionados
---
## 🔍 Cómo Funciona Ahora
### Flujo Completo
1. **Backend** (al cargar `/eskaera/<order_id>`):
- Obtiene TODOS los productos filtrados (sin paginar)
- Calcula `available_tags` con contadores correctos
- Pagina productos (20 por página con lazy loading)
- Pasa al template: `available_tags`, `products` (paginados)
2. **Template** (`website_templates.xml`):
- Renderiza badges con `t-esc="tag['count']"` (del backend)
- Renderiza productos con `data-product-tags="1,2,3"` (IDs de tags)
3. **JavaScript** (al cargar página):
- `_initializeAvailableTags()`: Lee contadores del DOM (una sola vez)
- `_storeAllProducts()`: Guarda productos cargados con sus tags
- Listeners de badges: Toggle selección visual + llamar `_filterProducts()`
4. **Usuario hace click en tag**:
- Se añade/remueve ID del tag de `selectedTags` Set
- Se actualizan colores de TODOS los badges (primario si seleccionado, secundario si no)
- Se llama `_filterProducts()`:
- Itera sobre `allProducts` (solo página actual)
- Aplica filtro: nombre AND categoría AND tags
- Añade/remueve clase `.hidden-product`
- **Contadores NO se recalculan** (mantienen valor del backend)
5. **Usuario carga más productos** (lazy loading):
- AJAX GET a `/eskaera/<order_id>/load-page?page=2`
- Backend retorna HTML con 20 productos más
- JavaScript hace `grid.insertAdjacentHTML('beforeend', html)`
- Se re-attach event listeners para qty +/-
- `_storeAllProducts()` NO se vuelve a llamar (❌ limitación actual)
- Tags seleccionados se aplican automáticamente al nuevo DOM
---
## ⚠️ Limitaciones Conocidas
### ~~1. Filtrado de Productos Cargados Dinámicamente~~ (✅ ARREGLADO)
**Problema**: Cuando se cargan nuevas páginas con lazy loading, los productos se añaden al DOM pero NO se añaden a `self.allProducts`. Esto significa que el filtrado solo se aplica a productos de la primera página.
**Solución Implementada** (líneas 420-436 de `infinite_scroll.js`):
```javascript
// Update realtime search to include newly loaded products
if (
window.realtimeSearch &&
typeof window.realtimeSearch._storeAllProducts === "function"
) {
window.realtimeSearch._storeAllProducts();
console.log("[INFINITE_SCROLL] Products list updated for realtime search");
// Apply current filters to newly loaded products
if (typeof window.realtimeSearch._filterProducts === "function") {
window.realtimeSearch._filterProducts();
console.log("[INFINITE_SCROLL] Filters applied to new products");
}
}
```
**Resultado**: ✅ Los productos cargados dinámicamente ahora:
1. Se añaden a `self.allProducts` automáticamente
2. Los filtros actuales (búsqueda, categoría, tags) se aplican inmediatamente
3. Mantienen consistencia de estado de filtrado
### ~~Workaround Anterior~~: Ya no necesario, arreglado en código.
### 2. Búsqueda y Categoría con Lazy Loading
**Problema Similar**: La búsqueda y filtrado por categoría tienen la misma limitación. Solo filtran productos ya cargados en el DOM.
**Solución Actual**: Usar `infiniteScroll.resetWithFilters()` para recargar desde servidor cuando cambian filtros de búsqueda/categoría.
---
## 🧪 Testing
### Casos de Prueba
#### Test 1: Contadores Correctos al Cargar
1. Abrir `/eskaera/<order_id>`
2. Verificar que badges muestran contadores correctos (del backend)
3. **Ejemplo**: Si "Ecológico" tiene 45 productos en total, debe decir "(45)" aunque solo se muestren 20 productos
**Resultado Esperado**: ✅ Contadores muestran total de productos con ese tag en dataset completo
#### Test 2: Filtrar por Tag
1. Abrir `/eskaera/<order_id>`
2. Hacer click en badge "Ecológico"
3. Verificar que:
- Badge "Ecológico" cambia a color primario
- Otros badges cambian a gris
- Solo productos con tag "Ecológico" visibles
- Productos sin tag ocultos (clase `.hidden-product`)
**Resultado Esperado**: ✅ Solo productos con tag seleccionado visibles
#### Test 3: Filtrar por Múltiples Tags (OR)
1. Hacer click en "Ecológico"
2. Hacer click en "Local"
3. Verificar que:
- Ambos badges primarios
- Productos con "Ecológico" OR "Local" visibles
- Productos sin ninguno de esos tags ocultos
**Resultado Esperado**: ✅ Lógica OR entre tags seleccionados
#### Test 4: Deseleccionar Todos
1. Con tags seleccionados, hacer click en el mismo tag para deseleccionar
2. Verificar que:
- Todos los badges vuelven a color original
- Todos los productos visibles de nuevo
**Resultado Esperado**: ✅ Estado inicial restaurado
#### Test 5: Contadores NO Cambian con Filtros
1. Seleccionar categoría "Verduras"
2. Verificar que contadores de tags NO cambian
3. Hacer búsqueda "tomate"
4. Verificar que contadores de tags NO cambian
**Resultado Esperado**: ✅ Contadores permanecen estáticos (calculados en backend sobre dataset completo)
---
## 📝 Código Relevante
### Template: Badges de Tags
**Archivo**: `views/website_templates.xml` (líneas 499-540)
```xml
<div id="tag-filter-container" class="tag-filter-badges">
<t t-foreach="available_tags" t-as="tag">
<t t-if="tag['color']">
<button
type="button"
class="badge tag-filter-badge"
t-att-data-tag-id="tag['id']"
t-att-data-tag-name="tag['name']"
t-att-data-tag-color="tag['color']"
t-attf-style="background-color: {{ tag['color'] }} !important; ..."
data-toggle="tag-filter"
>
<span t-esc="tag['name']"/> (<span class="tag-count" t-esc="tag['count']"/>)
</button>
</t>
<t t-else="">
<button
type="button"
class="badge tag-filter-badge tag-use-theme-color"
t-att-data-tag-id="tag['id']"
t-att-data-tag-name="tag['name']"
data-tag-color=""
data-toggle="tag-filter"
>
<span t-esc="tag['name']"/> (<span class="tag-count" t-esc="tag['count']"/>)
</button>
</t>
</t>
</div>
```
### Template: Producto con Tags
**Archivo**: `views/website_templates.xml` (líneas 1075-1080)
```xml
<div
class="product-card-wrapper product-card"
t-attf-data-product-name="{{ product.name }}"
t-attf-data-category-id="{{ product.categ_id.id if product.categ_id else '' }}"
t-attf-data-product-tags="{{ ','.join(str(t.id) for t in product.product_tag_ids) if product.product_tag_ids else '' }}"
>
```
**Nota**: Los tags se almacenan como string CSV de IDs: `"1,2,3"` → parseado en JS a `[1, 2, 3]`
### CSS: Ocultar Productos
**Archivo**: `static/src/css/base/utilities.css` (línea 32)
```css
.hidden-product {
display: none !important;
}
```
---
## 🚀 ~~Próximos Pasos (Opcional)~~ → COMPLETADO
### ~~Mejora 1: Filtrado con Lazy Loading~~ → ✅ IMPLEMENTADO
**~~Problema~~**: ~~Al cargar más páginas, los nuevos productos no se añaden a `self.allProducts`.~~
**Solución Implementada**:
```javascript
// En infiniteScroll.js, después de insertar HTML (líneas 420-436):
// Update realtime search to include newly loaded products
if (
window.realtimeSearch &&
typeof window.realtimeSearch._storeAllProducts === "function"
) {
window.realtimeSearch._storeAllProducts();
console.log("[INFINITE_SCROLL] Products list updated for realtime search");
// Apply current filters to newly loaded products
if (typeof window.realtimeSearch._filterProducts === "function") {
window.realtimeSearch._filterProducts();
console.log("[INFINITE_SCROLL] Filters applied to new products");
}
}
```
**Estado**: ✅ Implementado y funcional
### Mejora 2: Filtrado Dinámico en DOM
**Alternativa**: En lugar de mantener lista `self.allProducts`, buscar en DOM cada vez:
```javascript
_filterProducts: function () {
var self = this;
var productCards = document.querySelectorAll('.product-card');
productCards.forEach(function(card) {
// Parse attributes on the fly
var name = (card.getAttribute('data-product-name') || '').toLowerCase();
var categoryId = card.getAttribute('data-category-id') || '';
var tagIds = (card.getAttribute('data-product-tags') || '')
.split(',')
.map(id => parseInt(id.trim(), 10))
.filter(id => !isNaN(id));
// Apply filters...
});
}
```
**Ventajas**:
- ✅ Funciona con productos cargados dinámicamente
- ✅ No necesita re-inicializar después de lazy loading
**Desventajas**:
- ❌ Menos eficiente (parse en cada filtrado)
---
## 📚 Referencias
- [Odoo 18 QWeb Templates](https://www.odoo.com/documentation/18.0/developer/reference/frontend/qweb.html)
- [JavaScript Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set)
- [Array.prototype.some()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/some)
- [Lazy Loading Documentation](./LAZY_LOADING.md)
---
**Autor**: Criptomart SL
**Versión Addon**: 18.0.1.3.2
**Fecha**: 18 de febrero de 2026

View file

@ -428,6 +428,23 @@ console.log("[INFINITE_SCROLL] Script loaded!");
window.aplicoopShop._attachEventListeners();
console.log("[INFINITE_SCROLL] Event listeners re-attached");
}
// Update realtime search to include newly loaded products
if (
window.realtimeSearch &&
typeof window.realtimeSearch._storeAllProducts === "function"
) {
window.realtimeSearch._storeAllProducts();
console.log(
"[INFINITE_SCROLL] Products list updated for realtime search"
);
// Apply current filters to newly loaded products
if (typeof window.realtimeSearch._filterProducts === "function") {
window.realtimeSearch._filterProducts();
console.log("[INFINITE_SCROLL] Filters applied to new products");
}
}
})
.catch(function (error) {
console.error("[INFINITE_SCROLL] Fetch error:", error);

View file

@ -609,11 +609,9 @@
var visibleCount = 0;
var hiddenCount = 0;
// Track tag counts for dynamic badge updates
var tagCounts = {};
for (var tagId in self.availableTags) {
tagCounts[tagId] = 0;
}
// NOTE: Tag counts are NOT updated dynamically here because with lazy loading,
// self.allProducts only contains products from current page.
// Tag counts must remain as provided by backend (calculated on full dataset).
self.allProducts.forEach(function (product) {
var nameMatches = !searchQuery || product.name.indexOf(searchQuery) !== -1;
@ -633,35 +631,20 @@
if (shouldShow) {
product.element.classList.remove("hidden-product");
visibleCount++;
// Count this product's tags toward the dynamic counters
product.tags.forEach(function (tagId) {
if (tagCounts.hasOwnProperty(tagId)) {
tagCounts[tagId]++;
}
});
} else {
product.element.classList.add("hidden-product");
hiddenCount++;
}
});
// Update badge counts dynamically
for (var tagId in tagCounts) {
var badge = document.querySelector('[data-tag-id="' + tagId + '"]');
if (badge) {
var countSpan = badge.querySelector(".tag-count");
if (countSpan) {
countSpan.textContent = tagCounts[tagId];
}
}
}
console.log(
"[realtimeSearch] Filter result: visible=" +
visibleCount +
" hidden=" +
hiddenCount
hiddenCount +
" (selectedTags: " +
Array.from(self.selectedTags).join(",") +
")"
);
} catch (error) {
console.error("[realtimeSearch] ERROR in _filterProducts():", error.message);