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:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Customer Portal',
|
'name': 'Fusion Plating — Customer Portal',
|
||||||
'version': '19.0.4.0.0',
|
'version': '19.0.4.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||||
'CoC downloads, invoice access.',
|
'CoC downloads, invoice access.',
|
||||||
@@ -84,6 +84,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
|
'fusion_plating_portal/static/src/js/fp_rfq_form.js',
|
||||||
'fusion_plating_portal/static/src/js/fp_portal_sidebar.js', # NEW — Task 5
|
'fusion_plating_portal/static/src/js/fp_portal_sidebar.js', # NEW — Task 5
|
||||||
'fusion_plating_portal/static/src/js/fp_portal_account_summary.js', # NEW — Task 10 fix
|
'fusion_plating_portal/static/src/js/fp_portal_account_summary.js', # NEW — Task 10 fix
|
||||||
|
'fusion_plating_portal/static/src/js/fp_portal_list_search.js', # list search + sort
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'demo': [
|
'demo': [
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -201,6 +201,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- Reusable filter-pill + search + sort strip for portal list pages. -->
|
||||||
|
<!-- Render with t-call="fusion_plating_portal.fp_portal_list_controls" -->
|
||||||
|
<!-- and t-set the following vars in the call: -->
|
||||||
|
<!-- filters : list of (key, label) tuples for the pills -->
|
||||||
|
<!-- active_filter : the current filter key -->
|
||||||
|
<!-- sorts : list of (key, label) tuples for the sort dropdown-->
|
||||||
|
<!-- active_sort : the current sort key -->
|
||||||
|
<!-- search : current search string (for prefilling the input) -->
|
||||||
|
<!-- url : base path (e.g. '/my/jobs') -->
|
||||||
|
<!-- extra_qs : optional extra query-string suffix -->
|
||||||
|
<!-- target : CSS selector of the data-fp-filterable container -->
|
||||||
|
<!-- result_total : total record count (for the ">500" clip notice) -->
|
||||||
|
<!-- clipped : True if results were capped at 500 -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<template id="fp_portal_list_controls" name="FP Portal List Controls">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3 mb-3 o_fp_list_controls">
|
||||||
|
<!-- Filter pills -->
|
||||||
|
<div class="d-flex align-items-center gap-2" t-if="filters">
|
||||||
|
<span class="text-muted small">Showing:</span>
|
||||||
|
<t t-foreach="filters" t-as="f">
|
||||||
|
<a t-attf-href="#{url}?filter_state=#{f[0]}&sortby=#{active_sort}#{extra_qs or ''}"
|
||||||
|
t-attf-class="o_fp_filter_pill #{'o_fp_filter_pill_active' if active_filter == f[0] else ''}"
|
||||||
|
t-out="f[1]"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search input — real-time client-side filtering, no submit -->
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-2"
|
||||||
|
t-att-style="'flex: 1 1 auto; max-width: 360px;' + ('' if filters else 'margin-left: 0 !important')">
|
||||||
|
<input type="search"
|
||||||
|
class="form-control form-control-sm o_fp_list_search"
|
||||||
|
placeholder="Search…"
|
||||||
|
t-att-value="search or ''"
|
||||||
|
t-att-data-fp-target="target"
|
||||||
|
autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort dropdown — navigates on change (wired by fp_portal_list_search.js) -->
|
||||||
|
<select class="form-select form-select-sm o_fp_sort_select" style="max-width: 180px" t-if="sorts">
|
||||||
|
<t t-foreach="sorts" t-as="s">
|
||||||
|
<option t-att-value="url + '?filter_state=' + (active_filter or 'all') + '&sortby=' + s[0] + (extra_qs or '')"
|
||||||
|
t-att-selected="active_sort == s[0]"
|
||||||
|
t-out="s[1]"/>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Clip notice: shown server-side when >500 records were found -->
|
||||||
|
<div class="o_fp_list_search_meta small text-muted mb-2" t-if="clipped">
|
||||||
|
Showing latest 500 of <span t-out="result_total"/> — refine your filter to narrow further.
|
||||||
|
</div>
|
||||||
|
<!-- Live count: shown by JS while the user is typing in the search box -->
|
||||||
|
<div class="o_fp_list_search_count small text-muted mb-2 d-none"/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- Doc group (detail page) — pass label + docs list of dicts: -->
|
<!-- Doc group (detail page) — pass label + docs list of dicts: -->
|
||||||
<!-- {label, sub, url, icon_class, pending} -->
|
<!-- {label, sub, url, icon_class, pending} -->
|
||||||
|
|||||||
Reference in New Issue
Block a user