[FIX] web_tour: fix tour tips bad positions

**Before this commit**
1. When passing to the next step the new tour tip target stays
   the same element (could happen in certain conditions through
   owl's dom patching), the tour tip pointer is positionned at the
   [0, 0] bottom-left coordinates instead of near its target.

2. Under certain conditions, when the tour tip pointer's content
   changes, the dimensions of the opened tip is not properly stored
   in the instance of the tour pointer. This lead to a weird looking
   tip when you hover on it, triggering its opening state.

**After this commit**
The above issues are fixed.

closes odoo/odoo#137792

Taskid: 3502704
X-original-commit: f169130a31646dabb201cd4e1e54bbd0754b3fdf
Signed-off-by: Michaël Mattiello (mcm) <mcm@odoo.com>
Signed-off-by: Bruno Boi (boi) <boi@odoo.com>
This commit is contained in:
Bruno Boi
2023-10-06 07:12:19 +00:00
parent 495fb457c5
commit a9a8dd6795
4 changed files with 171 additions and 30 deletions
@@ -19,7 +19,6 @@ export class TourPointer extends Component {
shape: {
anchor: { type: HTMLElement, optional: true },
content: { type: String, optional: true },
fixed: { type: Boolean, optional: true },
isOpen: { type: Boolean, optional: true },
isVisible: { type: Boolean, optional: true },
onClick: { type: [Function, { value: null }], optional: true },
@@ -54,6 +53,7 @@ export class TourPointer extends Component {
let dimensions = null;
let lastMeasuredContent = null;
let lastOpenState = this.isOpen;
let lastAnchor;
let [anchorX, anchorY] = [0, 0];
useEffect(
@@ -90,7 +90,7 @@ export class TourPointer extends Component {
if (!this.isOpen) {
const { anchor } = this.props.pointerState;
if (anchor) {
if (anchor === lastAnchor) {
const { x, y, width } = anchor.getBoundingClientRect();
const [lastAnchorX, lastAnchorY] = [anchorX, anchorY];
[anchorX, anchorY] = [x, y];
@@ -104,35 +104,38 @@ export class TourPointer extends Component {
const wouldOverflow =
window.innerWidth - x - width / 2 < dimensions?.width;
el.classList.toggle("o_expand_left", wouldOverflow);
reposition(anchor, el, null, {
position: this.position,
margin: 6,
onPositioned: (popper, position) => {
const popperRect = popper.getBoundingClientRect();
const { top, left, direction } = position;
if (direction === "top") {
popper.style.bottom = `${
window.innerHeight - top - popperRect.height
}px`;
popper.style.removeProperty("top");
} else {
popper.style.top = `${top}px`;
}
if (direction === "left") {
popper.style.right = `${
window.innerWidth - left - popperRect.width
}px`;
popper.style.removeProperty("left");
} else {
popper.style.left = `${left}px`;
}
},
});
}
lastAnchor = anchor;
el.style.bottom = "";
el.style.right = "";
reposition(anchor, el, null, {
position: this.position,
margin: 6,
onPositioned: (popper, position) => {
const popperRect = popper.getBoundingClientRect();
const { top, left, direction } = position;
if (direction === "top") {
// position from the bottom instead of the top as it is needed
// to ensure the expand animation is properly done
popper.style.bottom = `${
window.innerHeight - top - popperRect.height
}px`;
popper.style.removeProperty("top");
} else if (direction === "left") {
// position from the right instead of the left as it is needed
// to ensure the expand animation is properly done
popper.style.right = `${
window.innerWidth - left - popperRect.width
}px`;
popper.style.removeProperty("left");
}
},
});
}
} else {
lastMeasuredContent = null;
lastOpenState = false;
lastAnchor = null;
dimensions = null;
}
},
@@ -8,7 +8,6 @@
o_tour_pointer
o_{{ position }}
{{ isOpen ? 'o_open' : (props.bounce ? 'o_bouncing' : '') }}
{{ props.pointerState.fixed ? 'position-fixed' : 'position-absolute' }}
{{ props.pointerState.onClick ? 'cursor-pointer' : '' }}
"
t-attf-style="
@@ -15,7 +15,6 @@ import { getScrollParent } from "./tour_utils";
* @typedef TourPointerState
* @property {HTMLElement} [anchor]
* @property {string} [content]
* @property {boolean} fixed
* @property {boolean} [isOpen]
* @property {() => {}} [onClick]
* @property {() => {}} [onMouseEnter]
@@ -60,7 +60,7 @@ QUnit.module("Tour service", (hooks) => {
start() {
super.start(...arguments);
macroEngines.push(this);
}
},
});
registerCleanup(() => {
macroEngines.forEach((e) => e.stop());
@@ -76,7 +76,7 @@ QUnit.module("Tour service", (hooks) => {
.add("tour_service", tourService);
patchWithCleanup(browser.console, {
// prevent form logging "tour successful" which would end the qunit suite test
log: () => {}
log: () => {},
});
});
@@ -135,6 +135,60 @@ QUnit.module("Tour service", (hooks) => {
assert.strictEqual(target.querySelector("span.value").textContent, "1");
});
QUnit.test("next step with new anchor at same position", async (assert) => {
registry.category("web_tour.tours").add("tour1", {
sequence: 10,
steps: () => [{ trigger: "button.foo" }, { trigger: "button.bar" }],
});
const env = await makeTestEnv({});
const { Component: OverlayContainer, props: overlayContainerProps } = registry
.category("main_components")
.get("OverlayContainer");
class Dummy extends Component {
state = useState({ bool: true });
static template = xml/*html*/ `
<button class="foo w-100" t-if="state.bool" t-on-click="() => { state.bool = false; }">Foo</button>
<button class="bar w-100" t-if="!state.bool">Bar</button>
`;
}
class Root extends Component {
static components = { OverlayContainer, Dummy };
static template = xml/*html*/ `
<t>
<Dummy />
<OverlayContainer t-props="props.overlayContainerProps" />
</t>
`;
}
await mount(Root, target, { env, props: { overlayContainerProps } });
env.services.tour_service.startTour("tour1", { mode: "manual" });
await mock.advanceTime(100);
assert.containsOnce(document.body, ".o_tour_pointer");
// check position of the pointer relative to the foo button
let pointerRect = document.body.querySelector(".o_tour_pointer").getBoundingClientRect();
let buttonRect = document.body.querySelector("button.foo").getBoundingClientRect();
const leftValue1 = pointerRect.left - buttonRect.left;
const bottomValue1 = pointerRect.bottom - buttonRect.bottom;
assert.ok(leftValue1 !== 0);
assert.ok(bottomValue1 !== 0);
await click(target, "button.foo");
await mock.advanceTime(100);
assert.containsOnce(document.body, ".o_tour_pointer");
// check position of the pointer relative to the bar button
pointerRect = document.body.querySelector(".o_tour_pointer").getBoundingClientRect();
buttonRect = document.body.querySelector("button.bar").getBoundingClientRect();
const leftValue2 = pointerRect.left - buttonRect.left;
const bottomValue2 = pointerRect.bottom - buttonRect.bottom;
assert.strictEqual(bottomValue1, bottomValue2);
assert.strictEqual(leftValue1, leftValue2);
});
QUnit.test("scroller pointer to reach next step", async function (assert) {
patchWithCleanup(Element.prototype, {
scrollIntoView(options) {
@@ -222,6 +276,92 @@ QUnit.module("Tour service", (hooks) => {
);
});
QUnit.test("scrolling to next step should update the pointer's height", async (assert) => {
patchWithCleanup(Element.prototype, {
scrollIntoView(options) {
super.scrollIntoView({ ...options, behavior: "instant" });
},
});
// The fixture should be shown for this test
target.style.position = "fixed";
target.style.top = "200px";
target.style.left = "50px";
const stepContent = "Click this pretty button to increment this magnificent counter !";
registry.category("web_tour.tours").add("tour1", {
sequence: 10,
steps: () => [
{
trigger: "button.inc",
content: stepContent,
},
],
});
const env = await makeTestEnv({});
const { Component: OverlayContainer, props: overlayContainerProps } = registry
.category("main_components")
.get("OverlayContainer");
class Root extends Component {
static components = { OverlayContainer, Counter };
static template = xml/*html*/ `
<div class="scrollable-parent" style="overflow-y: scroll; height: 150px;">
<Counter />
<div class="bottom-filler" style="height: 300px" />
</div>
<OverlayContainer t-props="props.overlayContainerProps" />
`;
}
await mount(Root, target, { env, props: { overlayContainerProps } });
env.services.tour_service.startTour("tour1", { mode: "manual" });
await mock.advanceTime(100); // awaits the macro engine
assert.containsOnce(document.body, ".o_tour_pointer");
assert.equal(document.body.querySelector(".o_tour_pointer").textContent, stepContent);
const pointer = document.body.querySelector(".o_tour_pointer");
assert.doesNotHaveClass(pointer, "o_open");
assert.strictEqual(pointer.style.height, "28px");
assert.strictEqual(pointer.style.width, "28px");
await triggerEvent(document.body, ".o_tour_pointer", "mouseenter");
await mock.advanceTime(100); // awaits for the macro engine next check cycle
assert.hasClass(pointer, "o_open");
const firstOpenHeight = pointer.style.height;
const firstOpenWidth = pointer.style.width;
await triggerEvent(document.body, ".o_tour_pointer", "mouseleave");
await mock.advanceTime(100); // awaits for the macro engine next check cycle
assert.doesNotHaveClass(pointer, "o_open");
document.querySelector(".scrollable-parent").scrollTop = 1000;
await nextTick(); // awaits the intersection observer to update after the scroll
await mock.advanceTime(100); // awaits for the macro engine next check cycle
// now the scroller pointer should be shown
assert.containsOnce(document.body, ".o_tour_pointer");
assert.equal(
document.body.querySelector(".o_tour_pointer").textContent,
"Scroll up to reach the next step."
);
document.querySelector(".scrollable-parent").scrollTop = 0;
await nextTick(); // awaits the intersection observer to update after the scroll
await mock.advanceTime(100); // awaits for the macro engine next check cycle
// now the true step pointer should be shown again
assert.containsOnce(document.body, ".o_tour_pointer");
assert.equal(document.body.querySelector(".o_tour_pointer").textContent, stepContent);
await triggerEvent(document.body, ".o_tour_pointer", "mouseenter");
await mock.advanceTime(100); // awaits for the macro engine next check cycle
assert.hasClass(pointer, "o_open");
const secondOpenHeight = pointer.style.height;
const secondOpenWidth = pointer.style.width;
assert.strictEqual(firstOpenHeight, secondOpenHeight);
assert.strictEqual(firstOpenWidth, secondOpenWidth);
});
QUnit.test("perform edit on next step", async function (assert) {
registry.category("web_tour.tours").add("tour1", {
sequence: 10,