From 2c49d1e092a1ffac0d882380a49f7e822727f450 Mon Sep 17 00:00:00 2001 From: Mariano Martinez Date: Wed, 24 Jun 2026 12:33:30 +0200 Subject: [PATCH 1/2] [IMP] base_fontawesome: add fontawesome_picker field widget Add a reusable OWL field widget that turns any Char storing a FontAwesome class into a searchable icon grid with live preview and a clear button, instead of typing the CSS class by hand. The catalog is read at runtime from the loaded stylesheets (the same approach as web_editor's icon selector, replicated here to depend on web only), so it always reflects the FontAwesome version shipped by this module. Registered on form views only. Covered by a QUnit suite. --- base_fontawesome/README.rst | 23 +- base_fontawesome/__manifest__.py | 6 + base_fontawesome/i18n/base_fontawesome.pot | 38 +++ base_fontawesome/readme/CONTRIBUTORS.rst | 1 + base_fontawesome/readme/USAGE.rst | 16 ++ .../static/description/index.html | 68 +++-- .../fontawesome_picker.esm.js | 210 +++++++++++++++ .../fontawesome_picker.scss | 52 ++++ .../fontawesome_picker/fontawesome_picker.xml | 70 +++++ .../tests/fontawesome_picker_tests.esm.js | 253 ++++++++++++++++++ 10 files changed, 716 insertions(+), 21 deletions(-) create mode 100644 base_fontawesome/static/src/fields/fontawesome_picker/fontawesome_picker.esm.js create mode 100644 base_fontawesome/static/src/fields/fontawesome_picker/fontawesome_picker.scss create mode 100644 base_fontawesome/static/src/fields/fontawesome_picker/fontawesome_picker.xml create mode 100644 base_fontawesome/static/tests/fontawesome_picker_tests.esm.js diff --git a/base_fontawesome/README.rst b/base_fontawesome/README.rst index 690f6ce0c35..f8be88648f8 100644 --- a/base_fontawesome/README.rst +++ b/base_fontawesome/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ================ Base Fontawesome ================ @@ -13,7 +17,7 @@ Base Fontawesome .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github @@ -55,6 +59,22 @@ For example, brand_icon is necessary if we are using an icon of a brand:: + + + + + + + +
+ +
+ +
+ +
+ +
+
+ +
+
+
+
+ + diff --git a/base_fontawesome/static/tests/fontawesome_picker_tests.esm.js b/base_fontawesome/static/tests/fontawesome_picker_tests.esm.js new file mode 100644 index 00000000000..a4a079bef03 --- /dev/null +++ b/base_fontawesome/static/tests/fontawesome_picker_tests.esm.js @@ -0,0 +1,253 @@ +/** @odoo-module **/ +/* global QUnit */ + +import {click, editInput, getFixture, mount} from "@web/../tests/helpers/utils"; +import {makeView, setupViewRegistries} from "@web/../tests/views/helpers"; +import {FontAwesomePickerGrid} from "@base_fontawesome/fields/fontawesome_picker/fontawesome_picker.esm"; +import {makeTestEnv} from "@web/../tests/helpers/mock_env"; + +// The field reads its catalog from the loaded v4-shims stylesheet +// (".fa.fa-" selectors). QUnit runs headless, so we inject a deterministic +// set of such rules, including a brand (fa-facebook) to prove brand icons are +// offered and stored with the universal "fa" prefix. +const FA_TEST_ICONS = [ + "fa-facebook", + "fa-search", + "fa-shopping-cart", + "fa-star", + "fa-undo", +]; +let styleEl = null; +let serverData = null; +let target = null; + +QUnit.module("base_fontawesome", (hooks) => { + hooks.before(() => { + styleEl = document.createElement("style"); + styleEl.textContent = FA_TEST_ICONS.map( + (name) => `.fa.${name}:before { content: "x"; }` + ).join("\n"); + document.head.appendChild(styleEl); + }); + + hooks.after(() => { + styleEl.remove(); + }); + + hooks.beforeEach(() => { + target = getFixture(); + serverData = { + models: { + partner: { + fields: { + icon: {string: "Icon", type: "char", searchable: true}, + }, + records: [ + {id: 1, icon: "fa fa-undo"}, + {id: 2, icon: false}, + ], + }, + }, + }; + setupViewRegistries(); + }); + + // --- Grid component in isolation (deterministic, controlled catalog) --- + + QUnit.module("FontAwesomePickerGrid"); + + QUnit.test("lists every icon and filters by search (V1, V2)", async (assert) => { + const env = await makeTestEnv(); + await mount(FontAwesomePickerGrid, target, { + env, + props: { + icons: FA_TEST_ICONS, + empty: false, + onSelect: () => undefined, + close: () => undefined, + }, + }); + assert.containsN(target, ".o_fa_picker_item", FA_TEST_ICONS.length); + await editInput(target, ".o_fa_picker_search", "shopping"); + assert.containsOnce(target, ".o_fa_picker_item"); + assert.strictEqual( + target.querySelector(".o_fa_picker_item").title, + "fa-shopping-cart" + ); + }); + + QUnit.test("selecting an icon notifies and closes (V3)", async (assert) => { + const env = await makeTestEnv(); + const selected = []; + let closed = false; + await mount(FontAwesomePickerGrid, target, { + env, + props: { + icons: FA_TEST_ICONS, + empty: false, + onSelect: (name) => selected.push(name), + close: () => { + closed = true; + }, + }, + }); + await click(target, ".o_fa_picker_item[title='fa-undo']"); + assert.deepEqual(selected, ["fa-undo"]); + assert.ok(closed, "the popover close was requested"); + }); + + QUnit.test("empty catalog shows a message (R2)", async (assert) => { + const env = await makeTestEnv(); + await mount(FontAwesomePickerGrid, target, { + env, + props: { + icons: [], + empty: true, + onSelect: () => undefined, + close: () => undefined, + }, + }); + assert.containsNone(target, ".o_fa_picker_item"); + assert.containsOnce(target, ".o_fa_picker_empty"); + }); + + QUnit.test( + "a search with no match shows neither items nor empty notice", + async (assert) => { + const env = await makeTestEnv(); + await mount(FontAwesomePickerGrid, target, { + env, + props: { + icons: FA_TEST_ICONS, + empty: false, + onSelect: () => undefined, + close: () => undefined, + }, + }); + await editInput(target, ".o_fa_picker_search", "zzz-nope"); + // No item matches, but this is "filtered to zero", not "empty catalog": + // the empty-catalog notice must NOT appear (different XML branch). + assert.containsNone(target, ".o_fa_picker_item"); + assert.containsNone(target, ".o_fa_picker_empty"); + } + ); + + QUnit.test("the rendered grid is capped and hints to refine", async (assert) => { + const env = await makeTestEnv(); + const many = Array.from( + {length: FontAwesomePickerGrid.MAX_RESULTS + 50}, + (unused, i) => `fa-gen-${i}` + ); + await mount(FontAwesomePickerGrid, target, { + env, + props: { + icons: many, + empty: false, + onSelect: () => undefined, + close: () => undefined, + }, + }); + assert.containsN( + target, + ".o_fa_picker_item", + FontAwesomePickerGrid.MAX_RESULTS + ); + assert.containsOnce(target, ".o_fa_picker_more"); + }); + + // --- Field widget through a form view --- + + QUnit.module("FontAwesomePicker"); + + QUnit.test("shows a preview and clears the value (V1, V3)", async (assert) => { + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
`, + }); + assert.containsOnce(target, ".o_fa_picker_toggle i.fa.fa-undo"); + assert.containsOnce(target, ".o_fa_picker_clear"); + await click(target, ".o_fa_picker_clear"); + assert.containsNone(target, ".o_fa_picker_clear"); + assert.strictEqual( + target.querySelector(".o_fa_picker_toggle span").textContent.trim(), + "Select an icon" + ); + }); + + QUnit.test( + "opening the picker writes the chosen class, including brands (V1, V3, V5)", + async (assert) => { + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: `
`, + }); + assert.containsNone(target, ".o_fa_picker_popover"); + await click(target, ".o_fa_picker_toggle"); + assert.containsOnce(target, ".o_fa_picker_popover"); + // Catalog is read from the loaded v4-shims stylesheet, not hard-coded; + // brand icons are offered and stored with the universal "fa" prefix + // (v4-shims maps "fa fa-" to the brands font, so it renders). + assert.containsOnce(target, ".o_fa_picker_item[title='fa-facebook']"); + await click(target, ".o_fa_picker_item[title='fa-facebook']"); + assert.containsNone( + target, + ".o_fa_picker_popover", + "popover closes on select" + ); + assert.strictEqual( + target.querySelector(".o_fa_picker_toggle span").textContent.trim(), + "fa fa-facebook" + ); + } + ); + + QUnit.test("a second click on the toggle closes the popover", async (assert) => { + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: `
`, + }); + await click(target, ".o_fa_picker_toggle"); + assert.containsOnce(target, ".o_fa_picker_popover"); + await click(target, ".o_fa_picker_toggle"); + assert.containsNone(target, ".o_fa_picker_popover", "toggling again closes it"); + }); + + QUnit.test( + "an empty value shows the placeholder and no clear button", + async (assert) => { + await makeView({ + type: "form", + resModel: "partner", + resId: 2, + serverData, + arch: `
`, + }); + assert.containsNone(target, ".o_fa_picker_clear"); + assert.strictEqual( + target.querySelector(".o_fa_picker_toggle span").textContent.trim(), + "Select an icon" + ); + } + ); + + QUnit.test("readonly renders the icon without a toggle (V4)", async (assert) => { + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
`, + }); + assert.containsOnce(target, ".o_fa_picker_readonly i.fa.fa-undo"); + assert.containsNone(target, ".o_fa_picker_toggle"); + }); +}); From aa327cd9496b010cf44d8939d54bd99d91c7ae60 Mon Sep 17 00:00:00 2001 From: Mariano Martinez Date: Thu, 25 Jun 2026 10:34:07 +0200 Subject: [PATCH 2/2] =?UTF-8?q?[I18N]=20base=5Ffontawesome:=20traducci?= =?UTF-8?q?=C3=B3n=20es=5FES=20del=20selector=20de=20iconos=20FontAwesome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- base_fontawesome/i18n/es_ES.po | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/base_fontawesome/i18n/es_ES.po b/base_fontawesome/i18n/es_ES.po index e69de29bb2d..89ad5fcc7bf 100644 --- a/base_fontawesome/i18n/es_ES.po +++ b/base_fontawesome/i18n/es_ES.po @@ -0,0 +1,52 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_fontawesome +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-24 11:44+0000\n" +"PO-Revision-Date: 2026-06-24 11:44+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: base_fontawesome +#. odoo-javascript +#: code:addons/base_fontawesome/static/src/fields/fontawesome_picker/fontawesome_picker.esm.js:0 +#, python-format +msgid "Clear" +msgstr "Limpiar" + +#. module: base_fontawesome +#. odoo-javascript +#: code:addons/base_fontawesome/static/src/fields/fontawesome_picker/fontawesome_picker.esm.js:0 +#, python-format +msgid "No FontAwesome icons available." +msgstr "No hay iconos de FontAwesome disponibles." + +#. module: base_fontawesome +#. odoo-javascript +#: code:addons/base_fontawesome/static/src/fields/fontawesome_picker/fontawesome_picker.esm.js:0 +#, python-format +msgid "Refine your search to narrow the list." +msgstr "Refina la búsqueda para acotar la lista." + +#. module: base_fontawesome +#. odoo-javascript +#: code:addons/base_fontawesome/static/src/fields/fontawesome_picker/fontawesome_picker.esm.js:0 +#, python-format +msgid "Search icon..." +msgstr "Buscar icono..." + +#. module: base_fontawesome +#. odoo-javascript +#: code:addons/base_fontawesome/static/src/fields/fontawesome_picker/fontawesome_picker.esm.js:0 +#, python-format +msgid "Select an icon" +msgstr "Seleccionar un icono"