diff --git a/docs/TAG_FILTER_FIX.md b/docs/TAG_FILTER_FIX.md new file mode 100644 index 0000000..126a4cd --- /dev/null +++ b/docs/TAG_FILTER_FIX.md @@ -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/`): + - 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//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/` +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/` +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 +
+ + + + + + + + +
+``` + +### Template: Producto con Tags + +**Archivo**: `views/website_templates.xml` (líneas 1075-1080) + +```xml +
+``` + +**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 diff --git a/website_sale_aplicoop/static/src/js/infinite_scroll.js b/website_sale_aplicoop/static/src/js/infinite_scroll.js index 1ccf661..89770fa 100644 --- a/website_sale_aplicoop/static/src/js/infinite_scroll.js +++ b/website_sale_aplicoop/static/src/js/infinite_scroll.js @@ -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); diff --git a/website_sale_aplicoop/static/src/js/realtime_search.js b/website_sale_aplicoop/static/src/js/realtime_search.js index c096bac..ef0079f 100644 --- a/website_sale_aplicoop/static/src/js/realtime_search.js +++ b/website_sale_aplicoop/static/src/js/realtime_search.js @@ -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);