feat(portal): reusable list-search JS + fp_portal_list_controls macro

Adds the shared infrastructure for real-time multi-keyword search on
portal list pages:

- static/src/js/fp_portal_list_search.js — vanilla-JS IIFE that wires
  every input.o_fp_list_search to the container at the selector in
  its data-fp-target. On every keystroke, walks the container's
  direct children and toggles display: none based on whether each
  row's textContent contains all whitespace-tokenised keywords. Also
  wires .o_fp_sort_select dropdowns on every page EXCEPT Account
  Summary (scoped by .o_fp_account_summary closest-ancestor check) so
  the existing fp_portal_account_summary.js handler isn't doubled up.

- views/fp_portal_macros.xml — new t-call macro
  fusion_plating_portal.fp_portal_list_controls that renders the
  filter pills + search input + sort dropdown strip in one block.
  Callers pass filters, sorts, active_filter, active_sort, search,
  url, extra_qs, target, result_total, clipped via t-set.

- __manifest__.py — registers the new JS in web.assets_frontend
  (after fp_portal_account_summary.js). Version bumps 19.0.4.0.0 ->
  19.0.4.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-18 00:06:02 -04:00
parent 281941c7ee
commit d9bdbd8e18
3 changed files with 149 additions and 1 deletions

View File

@@ -0,0 +1,92 @@
/**
* Fusion Plating — portal list search + filter UI helper.
*
* Provides client-side, real-time multi-keyword filtering for any portal
* list page that opts in via the markup contract below. Pure vanilla JS,
* no framework, no debounce (client-side filter is <1ms even on 500 rows).
*
* Markup contract:
* - One <input class="o_fp_list_search" data-fp-target="<container-selector>"/>
* - One container matching <container-selector> with attribute data-fp-filterable
* whose direct children are the filterable rows (e.g. <tr> in a tbody,
* or <div class="o_fp_job_card_wrap"> children of #fp_jobs_list).
* - Optional: <span class="o_fp_list_search_count"/> updates with N visible
* of M total when a filter is active. Empty (no text) when search is empty.
*
* Each row's textContent is matched against the user's keywords using a
* lowercase AND across whitespace-split tokens. Extra non-visible search
* terms can be added per row as <span class="d-none" data-fp-search="..."/>.
*
* Sort dropdown: any <select class="o_fp_sort_select"> on the page navigates
* to the selected option's value URL on change. This file wires ALL such
* selects on any page, so the scope is not limited to .o_fp_account_summary.
* fp_portal_account_summary.js limits itself to .o_fp_account_summary scope
* to avoid double-firing on the Account Summary page. These two files are
* safe to coexist as long as Account Summary wraps its select inside the
* .o_fp_account_summary container (which it does).
*/
(function () {
"use strict";
function initSearch() {
document.querySelectorAll("input.o_fp_list_search").forEach(function (input) {
var targetSelector = input.getAttribute("data-fp-target");
if (!targetSelector) { return; }
var container = document.querySelector(targetSelector);
if (!container) { return; }
var countEl = document.querySelector(".o_fp_list_search_count");
var totalRows = container.children.length;
function applyFilter() {
var raw = (input.value || "").trim().toLowerCase();
var tokens = raw.split(/\s+/).filter(Boolean);
var visible = 0;
Array.prototype.forEach.call(container.children, function (row) {
var text = (row.textContent || "").toLowerCase();
var match = tokens.length === 0 || tokens.every(function (t) {
return text.indexOf(t) !== -1;
});
row.style.display = match ? "" : "none";
if (match) { visible++; }
});
if (countEl) {
countEl.textContent = tokens.length === 0
? ""
: visible + " of " + totalRows + " matching";
// Toggle visibility
countEl.classList.toggle("d-none", tokens.length === 0);
}
}
input.addEventListener("input", applyFilter);
// Run once on load in case the input has a prefilled value
applyFilter();
});
}
function initSortSelects() {
// Wire ALL .o_fp_sort_select dropdowns that are NOT inside
// .o_fp_account_summary (that page has its own handler in
// fp_portal_account_summary.js to avoid double-firing).
document.querySelectorAll(".o_fp_sort_select").forEach(function (sel) {
if (sel.closest(".o_fp_account_summary")) { return; }
sel.addEventListener("change", function () {
if (sel.value) {
window.location.href = sel.value;
}
});
});
}
function init() {
initSearch();
initSortSelects();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();