diff --git a/addons/html_builder/static/src/builder/options/rating_option.js b/addons/html_builder/static/src/builder/options/rating_option.js
new file mode 100644
index 0000000000000..78cae5209d748
--- /dev/null
+++ b/addons/html_builder/static/src/builder/options/rating_option.js
@@ -0,0 +1,145 @@
+import { Plugin } from "@html_editor/plugin";
+import { registry } from "@web/core/registry";
+
+class RatingOptionPlugin extends Plugin {
+ static id = "RatingOption";
+ static dependencies = ["history", "media"];
+ selector = ".s_rating";
+ resources = {
+ builder_options: {
+ template: "html_builder.RatingOption",
+ selector: ".s_rating",
+ },
+ builder_actions: this.getActions(),
+ };
+ getActions() {
+ return {
+ setIcons: {
+ apply: ({ editingElement, param: iconParam }) => {
+ editingElement.dataset.icon = iconParam;
+ renderIcons(editingElement);
+ delete editingElement.dataset.activeCustomIcon;
+ delete editingElement.dataset.inactiveCustomIcon;
+ },
+ isApplied: ({ editingElement, param: iconParam }) =>
+ getIconType(editingElement) === iconParam,
+ },
+ customIcon: {
+ load: async ({ editingElement, param: customParam }) =>
+ new Promise((resolve) => {
+ const isCustomActive = customParam === "customActiveIcon";
+ const media = document.createElement("i");
+ media.className = isCustomActive
+ ? getActiveCustomIcons(editingElement)
+ : getInactiveCustomIcons(editingElement);
+ const mediaDialogParams = {
+ noImages: true,
+ noDocuments: true,
+ noVideos: true,
+ media,
+ save: (icon) => {
+ resolve(icon);
+ },
+ };
+ this.dependencies.media.openMediaDialog(mediaDialogParams, this.editable);
+ }),
+ apply: ({ editingElement, loadResult: savedIconEl, param: customParam }) => {
+ const isCustomActive = customParam === "customActiveIcon";
+ const customClass = savedIconEl.className;
+ const activeIconEls = getActiveIcons(editingElement);
+ const inactiveIconEls = getInactiveIcons(editingElement);
+ const iconEls = isCustomActive ? activeIconEls : inactiveIconEls;
+ iconEls.forEach((iconEl) => (iconEl.className = customClass));
+ const faClassActiveCustomIcons =
+ activeIconEls.length > 0
+ ? activeIconEls[0].getAttribute("class")
+ : customClass;
+ const faClassInactiveCustomIcons =
+ inactiveIconEls.length > 0
+ ? inactiveIconEls[0].getAttribute("class")
+ : customClass;
+ editingElement.dataset.activeCustomIcon = faClassActiveCustomIcons;
+ editingElement.dataset.inactiveCustomIcon = faClassInactiveCustomIcons;
+ editingElement.dataset.icon = "custom";
+ },
+ },
+ activeIconsNumber: {
+ apply: ({ editingElement, value }) => {
+ const nbActiveIcons = parseInt(value);
+ const nbTotalIcons = getAllIcons(editingElement).length;
+ createIcons({
+ editingElement: editingElement,
+ nbActiveIcons: nbActiveIcons,
+ nbTotalIcons: nbTotalIcons,
+ });
+ },
+ getValue: ({ editingElement }) => getActiveIcons(editingElement).length,
+ },
+ totalIconsNumber: {
+ apply: ({ editingElement, value }) => {
+ const nbTotalIcons = Math.max(parseInt(value), 1);
+ const nbActiveIcons = getActiveIcons(editingElement).length;
+ createIcons({
+ editingElement: editingElement,
+ nbActiveIcons: nbActiveIcons,
+ nbTotalIcons: nbTotalIcons,
+ });
+ },
+ getValue: ({ editingElement }) => getAllIcons(editingElement).length,
+ },
+ };
+ }
+}
+
+registry.category("website-plugins").add(RatingOptionPlugin.id, RatingOptionPlugin);
+
+function createIcons({ editingElement, nbActiveIcons, nbTotalIcons }) {
+ const activeIconEl = editingElement.querySelector(".s_rating_active_icons");
+ const inactiveIconEl = editingElement.querySelector(".s_rating_inactive_icons");
+ const iconEls = getAllIcons(editingElement);
+ [...iconEls].forEach((iconEl) => iconEl.remove());
+ for (let i = 0; i < nbTotalIcons; i++) {
+ if (i < nbActiveIcons) {
+ activeIconEl.appendChild(document.createElement("i"));
+ } else {
+ inactiveIconEl.append(document.createElement("i"));
+ }
+ }
+ renderIcons(editingElement);
+}
+function getActiveCustomIcons(editingElement) {
+ return editingElement.dataset.activeCustomIcon || "";
+}
+function getActiveIcons(editingElement) {
+ return editingElement.querySelectorAll(".s_rating_active_icons > i");
+}
+function getAllIcons(editingElement) {
+ return editingElement.querySelectorAll(".s_rating_icons i");
+}
+function getIconType(editingElement) {
+ return editingElement.dataset.icon;
+}
+function getInactiveCustomIcons(editingElement) {
+ return editingElement.dataset.inactiveCustomIcon || "";
+}
+function getInactiveIcons(editingElement) {
+ return editingElement.querySelectorAll(".s_rating_inactive_icons > i");
+}
+function renderIcons(editingElement) {
+ const iconType = getIconType(editingElement);
+ const icons = {
+ "fa-star": "fa-star-o",
+ "fa-thumbs-up": "fa-thumbs-o-up",
+ "fa-circle": "fa-circle-o",
+ "fa-square": "fa-square-o",
+ "fa-heart": "fa-heart-o",
+ };
+ const faClassActiveIcons =
+ iconType === "custom" ? getActiveCustomIcons(editingElement) : "fa " + iconType;
+ const faClassInactiveIcons =
+ iconType === "custom" ? getInactiveCustomIcons(editingElement) : "fa " + icons[iconType];
+ const activeIconEls = getActiveIcons(editingElement);
+ const inactiveIconEls = getInactiveIcons(editingElement);
+ activeIconEls.forEach((activeIconEl) => (activeIconEl.className = faClassActiveIcons));
+ inactiveIconEls.forEach((inactiveIconEl) => (inactiveIconEl.className = faClassInactiveIcons));
+}
diff --git a/addons/html_builder/static/src/builder/options/rating_option.xml b/addons/html_builder/static/src/builder/options/rating_option.xml
new file mode 100644
index 0000000000000..dbd76bfded9c5
--- /dev/null
+++ b/addons/html_builder/static/src/builder/options/rating_option.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ Stars
+ Thumbs
+ Circles
+ Squares
+ Hearts
+ Custom
+
+
+
+
+ Replace Icon
+
+
+
+ Replace Icon
+
+
+
+ /
+
+
+
+
+
+
+
+
+
+
+
+ Top
+ Left
+ None
+
+
+
+
+
diff --git a/addons/html_builder/static/src/img/options/size_large.svg b/addons/html_builder/static/src/img/options/size_large.svg
new file mode 100644
index 0000000000000..1354178068994
--- /dev/null
+++ b/addons/html_builder/static/src/img/options/size_large.svg
@@ -0,0 +1,10 @@
+
diff --git a/addons/html_builder/static/src/img/options/size_medium.svg b/addons/html_builder/static/src/img/options/size_medium.svg
new file mode 100644
index 0000000000000..00b3a3d43d0f8
--- /dev/null
+++ b/addons/html_builder/static/src/img/options/size_medium.svg
@@ -0,0 +1,10 @@
+
diff --git a/addons/html_builder/static/src/img/options/size_small.svg b/addons/html_builder/static/src/img/options/size_small.svg
new file mode 100644
index 0000000000000..aaa36a673855b
--- /dev/null
+++ b/addons/html_builder/static/src/img/options/size_small.svg
@@ -0,0 +1,10 @@
+
diff --git a/addons/html_builder/static/tests/options/rating_option.test.js b/addons/html_builder/static/tests/options/rating_option.test.js
new file mode 100644
index 0000000000000..0f73c29a61aab
--- /dev/null
+++ b/addons/html_builder/static/tests/options/rating_option.test.js
@@ -0,0 +1,73 @@
+import { defineWebsiteModels, setupWebsiteBuilder } from "../helpers";
+import { expect, test } from "@odoo/hoot";
+import { animationFrame, clear, click, fill } from "@odoo/hoot-dom";
+import { contains } from "@web/../tests/web_test_helpers";
+
+defineWebsiteModels();
+
+test("change rating score", async () => {
+ await setupWebsiteBuilder(
+ `
+
Quality
+
+
+
+
+
+
+
+
+
+
+
+
`
+ );
+ expect(":iframe .s_rating .s_rating_active_icons i").toHaveCount(3);
+ expect(":iframe .s_rating .s_rating_inactive_icons i").toHaveCount(2);
+ await contains(":iframe .s_rating").click();
+ await contains(".options-container [data-action-id='activeIconsNumber'] input").click();
+ await clear();
+ await fill("1");
+ expect(":iframe .s_rating .s_rating_active_icons i").toHaveCount(1);
+ await contains(".options-container [data-action-id='totalIconsNumber'] input").click();
+ await clear();
+ await fill("4");
+ expect(":iframe .s_rating .s_rating_inactive_icons i").toHaveCount(3);
+});
+test("Ensure order of operations when clicking very fast on two options", async () => {
+ await setupWebsiteBuilder(
+ `
+
Quality
+
+
+
+
+
+
+
+
+
+
+
+
`
+ );
+ await contains(":iframe .s_rating").click();
+ expect("[data-label='Icon'] .btn-primary.dropdown-toggle").toHaveText("Stars");
+ expect(":iframe .s_rating").not.toHaveAttribute("data-active-custom-icon");
+ await click(".options-container [data-action-id='customIcon']");
+ await click(".options-container [data-class-action='fa-2x']");
+ await animationFrame();
+ expect(":iframe .s_rating_icons").not.toHaveClass("fa-2x");
+ await contains(".modal-dialog .fa-glass").click();
+ expect(":iframe .s_rating").toHaveAttribute("data-active-custom-icon", "fa fa-glass");
+ expect("[data-label='Icon'] .btn-primary.dropdown-toggle").toHaveText("Custom");
+ expect(":iframe .s_rating_icons").toHaveClass("fa-2x");
+ await contains(".o-snippets-top-actions .fa-undo").click();
+ expect("[data-label='Icon'] .btn-primary.dropdown-toggle").toHaveText("Custom");
+ expect(":iframe .s_rating").toHaveAttribute("data-active-custom-icon", "fa fa-glass");
+ expect(":iframe .s_rating_icons").not.toHaveClass("fa-2x");
+ await contains(".o-snippets-top-actions .fa-undo").click();
+ expect("[data-label='Icon'] .btn-primary.dropdown-toggle").toHaveText("Stars");
+ expect(":iframe .s_rating").not.toHaveAttribute("data-active-custom-icon");
+ expect(":iframe .s_rating_icons").not.toHaveClass("fa-2x");
+});