Skip to content

Commit

Permalink
Steps option
Browse files Browse the repository at this point in the history
  • Loading branch information
loco-odoo authored and FrancoisGe committed Jan 27, 2025
1 parent 0cef7d7 commit fd9ed7e
Show file tree
Hide file tree
Showing 7 changed files with 335 additions and 29 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { registry } from "@web/core/registry";
import { Plugin } from "@html_editor/plugin";
import { hideInvisibleEl, showInvisibleEl } from "@html_builder/builder/plugins/visibility_plugin";
import { applyFunDependOnSelectorAndExclude } from "@html_builder/builder/options/utils";

export const device_visibility_option_selector = "section .row > div";

Expand Down Expand Up @@ -101,20 +102,3 @@ class DeviceVisibilityOptionPlugin extends Plugin {
registry
.category("website-plugins")
.add(DeviceVisibilityOptionPlugin.id, DeviceVisibilityOptionPlugin);

/**
* Apply a function on an element if the element matches the selector and does
* does not match the exclude.
* @param {Function} fn - The function to apply.
* @param {HTMLElement} editingEl - The element on which the function has to be
* applied.
* @param {String} selector - The selector that the editing element has to match
* to apply the function.
* @param {String} exclude - The selector that the editing element can not match
* to apply the function.
*/
function applyFunDependOnSelectorAndExclude(fn, editingEl, selector, exclude = false) {
if (editingEl.matches(selector) && !editingEl.matches(exclude)) {
fn(editingEl);
}
}
264 changes: 264 additions & 0 deletions addons/html_builder/static/src/builder/options/process_steps_option.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import { defaultBuilderComponents } from "../builder_components/default_builder_components";
import { coreBuilderActions } from "@html_builder/builder/core_builder_action_plugin";
import { applyFunDependOnSelectorAndExclude } from "@html_builder/builder/options/utils";
import { getCSSVariableValue } from "@html_builder/builder/utils/utils_css";
import { Plugin } from "@html_editor/plugin";
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";

class ProcessStepsOptionPlugin extends Plugin {
static id = "ProcessStepsOption";
selector = ".s_process_steps";
resources = {
builder_options: {
OptionComponent: ProcessStepsOption,
selector: this.selector,
},
builder_actions: this.getActions(),
content_updated_handlers: (rootEl) =>
applyFunDependOnSelectorAndExclude(reloadConnectors, rootEl, this.selector),
};
getActions() {
return {
changeConnector: {
...coreBuilderActions.classAction,
apply: ({ editingElement, param: className }) => {
coreBuilderActions.classAction.apply({
editingElement: editingElement,
param: className,
});
reloadConnectors(editingElement);
let markerEnd = "";
if (
[
"s_process_steps_connector_arrow",
"s_process_steps_connector_curved_arrow",
].includes(className)
) {
const arrowHeadEl = editingElement.querySelector(
".s_process_steps_arrow_head"
);
// The arrowhead id is set here so that they are different per snippet
if (!arrowHeadEl.id) {
arrowHeadEl.id = "s_process_steps_arrow_head" + Date.now();
}
markerEnd = `url(#${arrowHeadEl.id})`;
}
editingElement
.querySelectorAll(".s_process_step_connector path")
.forEach((path) => path.setAttribute("marker-end", markerEnd));
},
},
changeArrowColor: {
apply: ({ editingElement, param: colorValue }) => {
const htmlPropColor = getCSSVariableValue(colorValue);
const arrowHeadEl = editingElement
.closest(".s_process_steps")
.querySelector(".s_process_steps_arrow_head");
arrowHeadEl.querySelector("path").style.fill = htmlPropColor || colorValue;
},
},
};
}
}

const connectorOptionParams = [
{ key: "", param: "None" },
{ key: "s_process_steps_connector_line", param: "Line" },
{ key: "s_process_steps_connector_arrow", param: "Straight arrow" },
{ key: "s_process_steps_connector_curved_arrow", param: "Curved arrow" },
];

export class ProcessStepsOption extends Component {
static template = "html_builder.ProcessStepsOption";
static components = { ...defaultBuilderComponents };
static props = {};

setup() {
this.connectorOptionParams = connectorOptionParams;
}

getConnectorId(connectorOptionParamKey) {
return !connectorOptionParamKey ? "no_connector_opt" : "";
}
}
registry.category("website-plugins").add(ProcessStepsOptionPlugin.id, ProcessStepsOptionPlugin);

/**
* Width and position of the connectors should be updated when one of the
* steps is modified.
*
*/
function reloadConnectors(editingElement) {
const connectorOptionClasses = connectorOptionParams.map(
(connectorOptionParam) => connectorOptionParam.key
);
const type =
connectorOptionClasses.find(
(connectorOptionClass) =>
connectorOptionClass && editingElement.classList.contains(connectorOptionClass)
) || "";
// As the connectors are only visible in desktop, we can ignore the
// steps that are only visible in mobile.
const stepsEls = editingElement.querySelectorAll(
".s_process_step:not(.o_snippet_desktop_invisible)"
);
const nbBootstrapCols = 12;
let colsInRow = 0;

for (let i = 0; i < stepsEls.length - 1; i++) {
const connectorEl = stepsEls[i].querySelector(".s_process_step_connector");
const stepMainElementRect = getStepMainElementRect(stepsEls[i]);
const nextStepMainElementRect = getStepMainElementRect(stepsEls[i + 1]);
const stepSize = getClassSuffixedInteger(stepsEls[i], "col-lg-");
const nextStepSize = getClassSuffixedInteger(stepsEls[i + 1], "col-lg-");
const stepOffset = getClassSuffixedInteger(stepsEls[i], "offset-lg-");
const nextStepOffset = getClassSuffixedInteger(stepsEls[i + 1], "offset-lg-");
const stepPaddingTop = getClassSuffixedInteger(stepsEls[i], "pt");
const nextStepPaddingTop = getClassSuffixedInteger(stepsEls[i + 1], "pt");
const stepHeightDifference = stepPaddingTop - nextStepPaddingTop;
const hCurrentStepIconHeight = stepMainElementRect.height / 2;
const hNextStepIconHeight = nextStepMainElementRect.height / 2;

connectorEl.style.left = `calc(50% + ${stepMainElementRect.width / 2}px + 16px)`;
connectorEl.style.height = `${
stepMainElementRect.height + Math.abs(stepHeightDifference)
}px`;
connectorEl.style.width = `calc(${
(100 * (stepSize / 2 + nextStepOffset + nextStepSize / 2)) / stepSize
}% - ${stepMainElementRect.width / 2}px - ${nextStepMainElementRect.width / 2}px - 32px)`;

const marginType = stepHeightDifference < 0 ? "marginBottom" : "marginTop";
connectorEl.style[marginType] = `${0 - Math.abs(stepHeightDifference)}px`;

const isTheLastColOfRow =
nbBootstrapCols < colsInRow + stepSize + stepOffset + nextStepSize + nextStepOffset;
connectorEl.classList.toggle("d-none", isTheLastColOfRow);
colsInRow = isTheLastColOfRow ? 0 : colsInRow + stepSize + stepOffset;
// When we are mobile view, the connector is not visible, here we
// display it quickly just to have its size.
connectorEl.style.display = "block";
const { height, width } = connectorEl.getBoundingClientRect();
connectorEl.style.removeProperty("display");
if (type === "s_process_steps_connector_curved_arrow" && i % 2 === 0) {
connectorEl.style.transform = stepHeightDifference ? "unset" : "scale(1, -1)";
} else {
connectorEl.style.transform = "unset";
}
connectorEl.setAttribute("viewBox", `0 0 ${width} ${height}`);
connectorEl
.querySelector("path")
.setAttribute(
"d",
getPath(
type,
width,
height,
stepHeightDifference,
hCurrentStepIconHeight,
hNextStepIconHeight
)
);
}
}
/**
* Returns the number suffixed to the class given in parameter.
*
* @param {HTMLElement} el
* @param {String} classNamePrefix
* @returns {Integer}
*/
function getClassSuffixedInteger(el, classNamePrefix) {
const className = [...el.classList].find((cl) => cl.startsWith(classNamePrefix));
return className ? parseInt(className.replace(classNamePrefix, "")) : 0;
}
/**
* Returns the step's icon or content bounding rectangle.
*
* @param {HTMLElement}
* @returns {object}
*/
function getStepMainElementRect(stepEl) {
const iconEl = stepEl.querySelector(".s_process_step_number");
if (iconEl) {
return iconEl.getBoundingClientRect();
}
const contentEls = stepEl.querySelectorAll(".s_process_step_content > *");
// If there is no icon, the biggest text bloc in the content container
// will be chosen.
if (contentEls.length) {
const contentRects = [...contentEls].map((contentEl) => {
const range = document.createRange();
range.selectNodeContents(contentEl);
return range.getBoundingClientRect();
});
return contentRects.reduce((previous, current) =>
current.width > previous.width ? current : previous
);
}
return {};
}
/**
* Returns the svg path based on the type of connector.
*
* @param {string} type
* @param {integer} width
* @param {integer} height
* @returns {string}
*/
function getPath(
type,
width,
height,
stepHeightDifference,
hCurrentStepIconHeight,
hNextStepIconHeight
) {
const hHeight = height / 2;
switch (type) {
case "s_process_steps_connector_line": {
const verticalPaddingFactor = Math.abs(stepHeightDifference) / 8;
if (stepHeightDifference >= 0) {
return `M 0 ${
stepHeightDifference + hCurrentStepIconHeight - verticalPaddingFactor
} L ${width} ${hNextStepIconHeight + verticalPaddingFactor}`;
}
return `M 0 ${hCurrentStepIconHeight + verticalPaddingFactor} L ${width} ${
hNextStepIconHeight - stepHeightDifference - verticalPaddingFactor
}`;
}
case "s_process_steps_connector_arrow": {
// When someone plays with the y-axis, it adds the padding in
// multiple of 8px. so here we devide it by 8 to calculate the
// number of padding steps has been added.
const verticalPaddingFactor = (Math.abs(stepHeightDifference) / 8) * 1.5;
if (stepHeightDifference >= 0) {
return `M ${0.05 * width} ${
stepHeightDifference + hCurrentStepIconHeight - verticalPaddingFactor
} L ${0.95 * width - 6} ${hNextStepIconHeight + verticalPaddingFactor}`;
}
return `M ${0.05 * width} ${hCurrentStepIconHeight + verticalPaddingFactor} L ${
0.95 * width - 6
} ${Math.abs(stepHeightDifference) + hNextStepIconHeight - verticalPaddingFactor}`;
}
case "s_process_steps_connector_curved_arrow": {
if (stepHeightDifference == 0) {
return `M ${0.05 * width} ${hHeight * 1.2} Q ${width / 2} ${hHeight * 1.8} ${
0.95 * width - 6
} ${hHeight * 1.2}`;
} else if (stepHeightDifference > 0) {
return `M ${0.05 * width} ${stepHeightDifference + hCurrentStepIconHeight} Q ${
width * 0.75
} ${height * 0.75} ${0.5 * width - 6} ${hHeight} T ${
0.95 * width - 6
} ${hNextStepIconHeight}`;
}
return `M ${0.05 * width} ${hCurrentStepIconHeight} Q ${width * 0.75} ${
height * 0.005
} ${0.5 * width - 6} ${hHeight} T ${0.95 * width - 6} ${
Math.abs(stepHeightDifference) + hNextStepIconHeight
}`;
}
}
return "";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

<t t-name="html_builder.ProcessStepsOption">
<BuilderRow label.translate="Connector">
<BuilderSelect>
<t t-foreach="connectorOptionParams" t-as="connectorOptionParam" t-key="connectorOptionParam_index">
<BuilderSelectItem action="'changeConnector'" id="getConnectorId(connectorOptionParam.key)" actionParam="connectorOptionParam.key"><t t-out="connectorOptionParam.param"/></BuilderSelectItem>
</t>
</BuilderSelect>
<BuilderColorPicker
styleAction="'stroke'"
dependencies="'!no_connector_opt'"
applyTo="'.s_process_step_connector path'"
action="'changeArrowColor'"/>
</BuilderRow>
<!-- TODO:
<t t-call="website.snippet_options_background_options">
<t t-set="selector" t-value="'.s_process_step .s_process_step_number'"/>
<t t-set="with_colors" t-value="True"/>
<t t-set="with_images" t-value="False"/>
<t t-set="with_color_combinations" t-value="False"/>
<t t-set="with_gradients" t-value="True"/>
</t>
(probably SectionBackgroundOption in the current implementation)
-->
</t>

</templates>
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { applyFunDependOnSelectorAndExclude } from "@html_builder/builder/options/utils";
import { Plugin } from "@html_editor/plugin";
import { registry } from "@web/core/registry";

Expand Down Expand Up @@ -120,13 +121,11 @@ class TableOfContentOptionPlugin extends Plugin {
for (const navbar of root.querySelectorAll(".s_table_of_content_navbar")) {
navbar.setAttribute("contenteditable", "false");
}
const closestTocEl = root.closest(".s_table_of_content_main");
const tableOfContentMainEls = closestTocEl
? [closestTocEl]
: [...root.querySelectorAll(".s_table_of_content_main")];
for (const tableOfContentMainEl of tableOfContentMainEls) {
this.updateTableOfContentNavbar(tableOfContentMainEl);
}
applyFunDependOnSelectorAndExclude(
this.updateTableOfContentNavbar.bind(this),
root,
".s_table_of_content_main"
);
}

updateTableOfContentNavbar(tableOfContentMain) {
Expand Down
10 changes: 10 additions & 0 deletions addons/html_builder/static/src/builder/options/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function applyFunDependOnSelectorAndExclude(fn, rootEl, selector, exclude = "") {
const closestSelector = rootEl.closest(selector);
const selectorEls = closestSelector
? [closestSelector]
: [...rootEl.querySelectorAll(selector)];
const editingEls = selectorEls.filter((selectorEl) => !selectorEl.matches(exclude));
for (const editingEl of editingEls) {
fn(editingEl);
}
}
21 changes: 21 additions & 0 deletions addons/html_builder/static/tests/options/steps_options.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expect, test } from "@odoo/hoot";
import { contains } from "@web/../tests/web_test_helpers";
import { defineWebsiteModels, setupWebsiteBuilder } from "../helpers";
import { insertStructureSnippet } from "./helpers";

defineWebsiteModels();

test("modify the steps color", async () => {
const { getEditor } = await setupWebsiteBuilder("<div></div>");
const editor = getEditor();
await insertStructureSnippet(editor, "s_process_steps");
await contains(":iframe .s_process_steps").click();
await contains("[data-label='Connector'] .o_we_color_preview").click();
await contains(".o-overlay-item [data-color='#FF0000']").click();
expect(":iframe .s_process_steps .s_process_step path").toHaveStyle({
stroke: "rgb(255, 0, 0)",
});
expect(":iframe marker.s_process_steps_arrow_head").toHaveStyle({
fill: "rgb(255, 0, 0)",
});
});
Loading

0 comments on commit fd9ed7e

Please sign in to comment.