From e067fec1689c99c699b1de00bc505214fee63a8f Mon Sep 17 00:00:00 2001 From: webmaster Date: Tue, 3 Nov 2026 12:57:51 +0000 Subject: [PATCH] feat(freelancer-directory): full-text search, OCA/projects/language filters, standardised languages - Search box: substring, case- and accent-insensitive, over Name, description, Focus, specialities and language names; combines with every filter (AND). - Filters: OCA membership (any / member / not a member), Language (canonical names only), plus Country/Speciality; a project MINIMUM ('N+ projects'), rendered only when a threshold can narrow the list. - Languages standardised: a closed ISO 639-1 checklist on both forms, with the proficiency as a SEPARATE axis (language_levels, 'Spanish:beginner'). A stored 'Spanish - beginners' now maps to the language 'Spanish' and never becomes a filter option. - Fix: .dev-card/.dir-filters set display, which beat the UA [hidden] rule, so filtered-out cards and the JS-off filter bar stayed visible. - Deterministic order by Name (the endpoint's order alternates between reads). - Card states plainly that a photo shows only when it is a link (the stored value is base64 and is not rendered); admin Image field says the same. - specialities stays free text. --- odoo-community-developers/admin/index.html | 245 +++++++++- odoo-community-developers/index.html | 504 ++++++++++++++++++--- 2 files changed, 673 insertions(+), 76 deletions(-) diff --git a/odoo-community-developers/admin/index.html b/odoo-community-developers/admin/index.html index b93c2f1..bb384f2 100644 --- a/odoo-community-developers/admin/index.html +++ b/odoo-community-developers/admin/index.html @@ -115,6 +115,50 @@ color: var(--ink); width: 100%; } + /* The standardised language multi-select: a closed, scrollable checklist. The + level Separate with commas. -
- - - Separate with commas. -
+
+ Languages +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Each language is stored by its standard name, so the directory filter never gets a name like “Spanish - beginners”. A level is optional. +
@@ -539,6 +700,7 @@
+ A link (https://…). A photo shows on the card only when this is a link — an inline/base64 image is not displayed.
-
+
+
+
+ +
-
+
@@ -722,7 +821,7 @@
Projects on ODOO4projects
4 projects — a number we recorded

No contact details published for this freelancer.

-
+
@@ -742,7 +841,7 @@
- +

You deal with the freelancer directly. The contract for the work is between you and them, and the terms, the price and the delivery are yours to agree. We are not a party to it — we publish the listings and, if you ask, pass your message along.

@@ -1089,11 +1188,128 @@ Separate with commas. The versions you actually work in.
-
- - - Separate with commas. -
+
+ Languages +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Pick the languages you work in. Each is stored by its standard name, so the filter never gets a name like “Spanish - beginners”. A level is optional and kept apart from the name. +
@@ -1299,6 +1515,58 @@ ); } + /* Lower-case and strip accents, so "Zürich" matches "zurich" (search + dedupe). */ + function fold(text) { + return String(text == null ? "" : text) + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); + } + + /* Comma-separated tokens from any shape of field: a text box split on commas, or a + group of checkboxes (the standardised `languages` field) read as the checked + values. Trims and drops blanks — one wire shape either way. */ + function collectCsvTokens(els) { + var out = []; + els.forEach(function (el) { + if (el.type === "checkbox" || el.type === "radio") { + if (el.checked && el.value) out.push(el.value.trim()); + } else { + (el.value || "").split(",").forEach(function (part) { out.push(part.trim()); }); + } + }); + return out.filter(Boolean); + } + + /* The standard language names, read off this page's own language checklist — the + list is already in the form, so no second copy is shipped. */ + function canonicalLanguages() { + var names = []; + Array.prototype.forEach.call( + document.querySelectorAll('input[name="languages"]'), + function (box) { if (box.value) names.push(box.value); } + ); + return names; + } + + /* Map stored language tokens to standard names: "Spanish - beginners" -> "Spanish". + A token matching no standard name yields nothing (it still shows as a tag). */ + function canonicalFor(tokens) { + var names = canonicalLanguages(); + var out = []; + tokens.forEach(function (token) { + var ft = fold(token), best = null; + names.forEach(function (n) { + var fn = fold(n); + if (ft === fn || ft.indexOf(fn + " ") === 0 || ft.indexOf(fn + "-") === 0 || ft.indexOf(fn + "(") === 0) { + if (best === null || n.length > best.length) best = n; + } + }); + if (best && out.map(fold).indexOf(fold(best)) === -1) out.push(best); + }); + return out; + } + /* The Country value to submit: the ISO code by default, the English name when COUNTRY_VALUE_FORMAT is "name". Empty until the visitor picks a country. */ function countryValue(form) { @@ -1347,17 +1615,30 @@ return; } + if (name === "language_level") { + // Proficiency is a SEPARATE AXIS: a level rides in `language_levels` + // ("Spanish:beginner"), never inside a language name. The language NAME in + // `languages` is always a standard one, so the filter can never see a + // free-text "Spanish - beginners". + var pairs = []; + Array.prototype.forEach.call( + form.querySelectorAll('input[name="languages"]:checked'), + function (box) { + var row = box.closest(".lang-pick"); + var sel = row ? row.querySelector("select") : null; + if (sel && sel.value) pairs.push(box.value + ":" + sel.value); + } + ); + if (pairs.length) payload.language_levels = pairs.join(","); + return; + } + if (CSV_STRING_FIELDS.indexOf(name) !== -1) { - // Normalise to the wire shape: trim each entry, drop blanks, re-join. - // "English, German" and "a, b, ,c" → "English,German" and "a,b,c". - var norm = (els[0].value || "") - .split(",") - .map(function (s) { - return s.trim(); - }) - .filter(Boolean) - .join(","); - if (norm) payload[name] = norm; // an empty box must not wipe the field + // Normalise to the wire shape: trim each entry, drop blanks, re-join. A text + // box is split on commas; the `languages` checklist is read as its checked + // values. "English, German" → "English,German"; nothing checked → omitted. + var norm = collectCsvTokens(els).join(","); + if (norm) payload[name] = norm; // an empty selection must not wipe the field return; } @@ -1433,10 +1714,12 @@ the endpoint returns with a `Name` is rendered, exactly as build-directory.py does it. The workflow has to expose a marker before this can gate on one. */ var LIVE_FILTERS = [ - ["Country", "Country", "country"], - ["odoo_versions", "Odoo version", "odoo-versions"], - ["languages", "Language", "languages"], - ["specialities", "Speciality", "specialities"] + ["Country", "Country", "country", "value"], + ["odoo_versions", "Odoo version", "odoo-versions", "value"], + ["languages", "Language", "languages", "language"], + ["specialities", "Speciality", "specialities", "value"], + ["OCA_member", "OCA membership", "oca", "bool"], + ["odoo4projects_clients", "Projects on ODOO4projects", "projects", "min"] ]; // The webhook's casing has drifted once already. Accept the known aliases too — // the same list as FIELD_ALIASES in build-directory.py, so both renderers agree. @@ -1465,16 +1748,40 @@ return node; } + /* Everything the freelancer wrote about themselves, folded, for the search box: + Name, description, Focus, specialities and the language names. */ + function liveSearchText(rec) { + return fold([ + String(rec.Name || ""), + String(rec.description || ""), + String(rec.Focus || ""), + liveValues(rec, "specialities").join(" "), + liveValues(rec, "languages").join(" ") + ].join(" ")); + } + + /* The card's data- value for one active filter — mirrors attr_value() in + build-directory.py, field for field. */ + function liveAttrValue(rec, pair) { + var field = pair[0], kind = pair[3]; + if (kind === "bool") return rec[field] === true ? "1" : "0"; + if (kind === "min") { + var n = parseInt(rec[field], 10); + return isNaN(n) ? "" : String(n); + } + if (kind === "language") return canonicalFor(liveValues(rec, field)).map(fold).join("|"); + return liveValues(rec, field).map(fold).join("|"); + } + /* One card, field for field the same as render_card() in build-directory.py. Everything a freelancer wrote goes in as textContent, never as HTML. */ function liveCard(rec, active) { var name = String(rec.Name || "").trim(); var card = liveEl("article", "dev-card"); card.setAttribute("data-name", name.toLowerCase()); + card.setAttribute("data-search", liveSearchText(rec)); active.forEach(function (pair) { - card.setAttribute("data-" + pair[2], liveValues(rec, pair[0]).map(function (v) { - return v.toLowerCase(); - }).join("|")); + card.setAttribute("data-" + pair[2], liveAttrValue(rec, pair)); }); var photo = String(rec.Image || "").trim(); @@ -1578,41 +1885,73 @@ return map; } - /* The filter bar, rebuilt from the records just fetched. Same rule as the build: - a filter appears only when at least one record carries a value for it. */ + /* The filter bar, rebuilt from the records just fetched. Same rule as the build: a + filter appears only when the data supports it. A project minimum is additionally + printed only when a threshold can narrow the list (two distinct counts); with one + count it would return everyone, so it is not shipped. */ function liveFilters(records) { var bar = document.getElementById("dirFilters"); var countries = countryNames(); - while (bar.firstChild) bar.removeChild(bar.firstChild); + var clearWrap = bar.querySelector(".dir-clear-wrap"); + Array.prototype.slice.call(bar.querySelectorAll(".dir-filter")).forEach(function (node) { + if (!node.classList.contains("dir-search") && !node.classList.contains("dir-clear-wrap")) { + bar.removeChild(node); + } + }); var active = []; LIVE_FILTERS.forEach(function (pair) { - var seen = []; - records.forEach(function (rec) { - liveValues(rec, pair[0]).forEach(function (v) { - var dup = seen.some(function (s) { return s.toLowerCase() === v.toLowerCase(); }); - if (!dup) seen.push(v); + var field = pair[0], label = pair[1], attr = pair[2], kind = pair[3]; + var options = null; + + if (kind === "bool") { + var anyBool = records.some(function (r) { + return r[field] !== null && r[field] !== undefined && r[field] !== ""; }); - }); - if (!seen.length) return; // no data → no filter + if (anyBool) options = [["1", "OCA member"], ["0", "Not an OCA member"]]; + } else if (kind === "min") { + var counts = []; + records.forEach(function (r) { + var n = parseInt(r[field], 10); + if (!isNaN(n) && counts.indexOf(n) === -1) counts.push(n); + }); + counts.sort(function (a, b) { return a - b; }); + if (counts.length >= 2) { + options = counts.slice(1).map(function (c) { return [String(c), c + "+ projects"]; }); + } + } else { + var seen = []; + records.forEach(function (rec) { + var vals = kind === "language" ? canonicalFor(liveValues(rec, field)) : liveValues(rec, field); + vals.forEach(function (v) { + if (!seen.some(function (s) { return fold(s) === fold(v); })) seen.push(v); + }); + }); + if (seen.length) { + seen.sort(function (a, b) { return fold(a) < fold(b) ? -1 : 1; }); + options = seen.map(function (v) { + var shown = field === "Country" ? (countries[v.trim().toUpperCase()] || v) : v; + return [fold(v), shown]; + }); + } + } + if (!options) return; // no data → no filter + active.push(pair); var wrap = liveEl("div", "dir-filter"); - var id = "f-" + pair[2]; - var label = liveEl("label", null, pair[1]); - label.setAttribute("for", id); + var id = "f-" + attr; + var lbl = liveEl("label", null, label); + lbl.setAttribute("for", id); var sel = document.createElement("select"); sel.id = id; - sel.setAttribute("data-field", pair[2]); + sel.setAttribute("data-field", attr); + if (kind === "min") sel.setAttribute("data-match", "min"); sel.appendChild(new Option("All", "")); - seen.sort(function (a, b) { - return a.toLowerCase() < b.toLowerCase() ? -1 : 1; - }).forEach(function (v) { - sel.appendChild(new Option( - pair[0] === "Country" ? (countries[v.trim().toUpperCase()] || v) : v, - v.toLowerCase())); - }); - wrap.appendChild(label); + options.forEach(function (o) { sel.appendChild(new Option(o[1], o[0])); }); + wrap.appendChild(lbl); wrap.appendChild(sel); - bar.appendChild(wrap); + // Keep the selects between the static Search box and the static Clear button. + if (clearWrap) bar.insertBefore(wrap, clearWrap); + else bar.appendChild(wrap); }); return active; } @@ -1630,6 +1969,12 @@ // A record with no Name is not a freelancer (build-directory.py skips it too). var records = all.filter(function (r) { return r && String(r.Name || "").trim(); }); if (!records.length) return; // never trade a stale list for an empty one + // Deterministic, NEUTRAL order by Name — never by project count (no ranking), + // and never the endpoint's own order, which alternates between reads. + records.sort(function (a, b) { + var fa = fold(a.Name), fb = fold(b.Name); + return fa < fb ? -1 : (fa > fb ? 1 : 0); + }); var active = liveFilters(records); while (grid.firstChild) grid.removeChild(grid.firstChild); records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); }); @@ -1642,39 +1987,74 @@ function initFilters() { var bar = document.getElementById("dirFilters"); if (!bar) return; - // One listener for the life of the page. It re-reads the selects on every change, - // so the same code filters the build-time list and a runtime-rendered one. + // One delegated listener for the life of the page: it re-reads the controls on + // every change, so the same code filters the build-time list and a runtime one. bar.addEventListener("change", function (event) { if (event.target && event.target.tagName === "SELECT") applyFilters(); }); + bar.addEventListener("input", function (event) { + if (event.target && event.target.hasAttribute("data-search-box")) applyFilters(); + }); + var clear = document.getElementById("dirClear"); + if (clear) { + clear.addEventListener("click", function () { + var box = bar.querySelector("[data-search-box]"); + if (box) box.value = ""; + Array.prototype.forEach.call(bar.querySelectorAll("select"), function (s) { s.value = ""; }); + applyFilters(); + }); + } applyFilters(); } + /* Search AND filters combine: a card shows only when it matches every control — + a folded substring for the search box, equality for a value filter, a minimum + for the project count. The same logic covers the build-time list and a runtime + one. A filter that matches nobody shows the explained empty state, never a blank + page. */ function applyFilters() { var bar = document.getElementById("dirFilters"); var grid = document.getElementById("dirGrid"); var count = document.getElementById("dirCount"); var empty = document.getElementById("dirEmpty"); + var clear = document.getElementById("dirClear"); if (!bar || !grid) return; + var search = bar.querySelector("[data-search-box]"); + var query = search ? fold(search.value.trim()) : ""; var selects = Array.prototype.slice.call(bar.querySelectorAll("select")); - bar.hidden = !selects.length; // no data supports any filter → no filter bar - if (count) count.hidden = !selects.length; var cards = Array.prototype.slice.call(grid.querySelectorAll(".dev-card")); + var nothing = cards.length === 0; + + bar.hidden = nothing; + if (count) count.hidden = nothing; + if (clear) { + clear.hidden = nothing || (!query && !selects.some(function (s) { return s.value; })); + } var shown = 0; cards.forEach(function (card) { - var ok = selects.every(function (sel) { - var want = sel.value; - if (!want) return true; - var have = (card.getAttribute("data-" + sel.getAttribute("data-field")) || "").split("|"); - return have.indexOf(want) !== -1; - }); + var ok = true; + if (query) ok = (card.getAttribute("data-search") || "").indexOf(query) !== -1; + if (ok) { + selects.forEach(function (sel) { + if (!ok) return; + var want = sel.value; + if (!want) return; + var have = card.getAttribute("data-" + sel.getAttribute("data-field")) || ""; + if (sel.getAttribute("data-match") === "min") { + var n = parseInt(have, 10); + if (isNaN(n) || n < parseInt(want, 10)) ok = false; + } else if (have.split("|").indexOf(want) === -1) { + ok = false; + } + }); + } card.hidden = !ok; if (ok) shown++; }); - if (count && selects.length) count.textContent = "Showing " + shown + " of " + cards.length; - if (empty) empty.hidden = selects.length ? shown !== 0 : true; + if (count && !count.hidden) count.textContent = "Showing " + shown + " of " + cards.length; + if (empty) empty.hidden = nothing ? true : shown !== 0; } /* ---- Form 1: freelancer application (create — no id) ---- */