[IMP] web: add pager to new framework

Before this commit, Pager was not implemented in the new framework.

Part-of: odoo/odoo#76472
This commit is contained in:
Michael (mcm)
2021-11-02 10:45:14 +00:00
parent 3ef8083dcb
commit d1c8a565ab
13 changed files with 667 additions and 84 deletions
+168
View File
@@ -0,0 +1,168 @@
/** @odoo-module **/
import { useAutofocus } from "../utils/hooks";
const { Component } = owl;
const { useExternalListener, useRef, useState } = owl.hooks;
/**
* Pager
*
* The pager goes from 1 to total (included).
* The current value is minimum if limit === 1 or the interval:
* [minimum, minimum + limit[ if limit > 1].
* The value can be manually changed by clicking on the pager value and giving
* an input matching the pattern: min[,max] (in which the comma can be a dash
* or a semicolon).
* The pager also provides two buttons to quickly change the current page (next
* or previous).
* @extends Component
*/
export class Pager extends Component {
setup() {
this.state = useState({
isEditing: false,
isDisabled: false,
});
this.inputRef = useRef("input");
useExternalListener(document, "mousedown", this.onClickAway, { capture: true });
this.forceFocus = useAutofocus();
}
/**
* @returns {number}
*/
get minimum() {
return this.props.offset + 1;
}
/**
* @returns {number}
*/
get maximum() {
return Math.min(this.props.offset + this.props.limit, this.props.total);
}
/**
* @returns {string}
*/
get value() {
const parts = [this.minimum];
if (this.props.limit > 1) {
parts.push(this.maximum);
}
return parts.join("-");
}
/**
* @returns {boolean} true iff there is only one page
*/
get isSinglePage() {
return this.minimum === 1 && this.maximum === this.props.total;
}
/**
* @param {-1 | 1} direction
*/
navigate(direction) {
let minimum = this.props.offset + this.props.limit * direction;
if (minimum >= this.props.total) {
minimum = 0;
} else if (minimum < 0 && this.props.limit === 1) {
minimum = this.props.total - 1;
} else if (minimum < 0 && this.props.limit > 1) {
minimum = this.props.total - (this.props.total % this.props.limit || this.props.limit);
}
this.update(minimum, this.props.limit);
}
/**
* @param {string} value
* @returns {{ minimum: number, maximum: number }}
*/
parse(value) {
let [minimum, maximum] = value.trim().split(/\s*[\-\s,;]\s*/);
const clamp = (value) => Math.min(Math.max(value, 1), this.props.total);
return {
minimum: clamp(parseInt(minimum, 10)) - 1,
maximum: clamp(maximum ? parseInt(maximum, 10) : minimum),
};
}
/**
* @param {string} value
*/
setValue(value) {
const { minimum, maximum } = this.parse(value);
if (!isNaN(minimum) && !isNaN(maximum) && minimum < maximum) {
this.update(minimum, maximum - minimum);
}
}
/**
* @param {number} offset
* @param {number} limit
*/
async update(offset, limit) {
this.state.isDisabled = true;
await this.props.onUpdate({ offset, limit });
this.state.isDisabled = false;
this.state.isEditing = false;
}
/**
* @param {MouseEvent} ev
*/
onClickAway(ev) {
if (ev.target !== this.inputRef.el) {
this.state.isEditing = false;
}
}
onInputBlur() {
this.state.isEditing = false;
}
/**
* @param {Event} ev
*/
onInputChange(ev) {
this.setValue(ev.target.value);
if (!this.state.isDisabled) {
ev.preventDefault();
}
}
/**
* @param {KeyboardEvent} ev
*/
onInputKeydown(ev) {
switch (ev.key) {
case "Enter":
ev.preventDefault();
ev.stopPropagation();
this.setValue(ev.currentTarget.value);
break;
case "Escape":
ev.preventDefault();
ev.stopPropagation();
this.state.isEditing = false;
break;
}
}
onValueClick() {
if (this.props.isEditable && !this.state.isEditing && !this.state.isDisabled) {
this.forceFocus();
this.state.isEditing = true;
}
}
}
Pager.template = "web.Pager";
Pager.defaultProps = {
isEditable: true,
withAccessKey: true,
};
Pager.props = {
offset: Number,
limit: Number,
total: Number,
onUpdate: Function,
isEditable: Boolean,
withAccessKey: Boolean,
};
@@ -0,0 +1,24 @@
.o_pager {
display: flex;
align-items: center;
.o_pager_counter {
min-width: $o-statbutton-height;
}
.o_pager_value {
display: inline-block;
}
span.o_pager_value {
border-bottom: 1px solid transparent;
}
input.o_pager_value {
text-align: right;
width: 60px;
&:focus {
outline: none;
}
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Pager" owl="1">
<nav class="o_pager" aria-label="Pager">
<span class="o_pager_counter mr-2" t-on-click.stop="">
<t t-if="state.isEditing">
<input type="text" class="o_pager_value o_input" t-ref="input" autofocus="" t-att-value="value" t-on-blur="onInputBlur" t-on-change="onInputChange" t-on-keydown.stop="onInputKeydown" />
</t>
<t t-else="">
<span class="o_pager_value" t-esc="value" t-on-click="onValueClick" />
</t>
<span> / </span>
<span class="o_pager_limit" t-esc="props.total" />
</span>
<span class="btn-group" aria-atomic="true">
<!-- accesskeys not wanted in X2Many widgets -->
<button type="button" class="fa fa-chevron-left btn btn-secondary o_pager_previous px-2 rounded-left" aria-label="Previous" data-tooltip="Previous" tabindex="-1" t-att-data-hotkey="props.withAccessKey ? 'p' : false" t-att-disabled="state.isDisabled or isSinglePage" t-on-click.stop="navigate(-1)" />
<button type="button" class="fa fa-chevron-right btn btn-secondary o_pager_next px-2 rounded-right" aria-label="Next" data-tooltip="Next" tabindex="-1" t-att-data-hotkey="props.withAccessKey ? 'n' : false" t-att-disabled="state.isDisabled or isSinglePage" t-on-click.stop="navigate(1)" />
</span>
</nav>
</t>
</templates>
@@ -219,7 +219,7 @@ odoo.define('web.Pager', function (require) {
validate: Function,
withAccessKey: Boolean,
};
Pager.template = 'web.Pager';
Pager.template = 'web.legacy.Pager';
return Pager;
});
+1 -1
View File
@@ -292,7 +292,7 @@
</div>
</t>
<t t-name="web.Pager" owl="1">
<t t-name="web.legacy.Pager" owl="1">
<nav class="o_pager" aria-label="Pager">
<span class="o_pager_counter mr-2" t-on-click.stop="">
<input t-if="state.editing" type="text"
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { Pager } from "@web/core/pager/pager";
import { useService } from "@web/core/utils/hooks";
import { ComparisonMenu } from "../comparison_menu/comparison_menu";
import { FavoriteMenu } from "../favorite_menu/favorite_menu";
@@ -114,5 +115,12 @@ export class ControlPanel extends Component {
}
}
ControlPanel.components = { ComparisonMenu, FavoriteMenu, FilterMenu, GroupByMenu, SearchBar };
ControlPanel.components = {
ComparisonMenu,
FavoriteMenu,
FilterMenu,
GroupByMenu,
Pager,
SearchBar,
};
ControlPanel.template = "web.ControlPanel";
@@ -78,15 +78,6 @@
padding-left: 5px;
text-align: center;
user-select: none;
.o_pager {
display: flex;
align-items: center;
.o_pager_counter {
min-width: $o-statbutton-height;
}
}
}
> .o_cp_switch_buttons {
@@ -117,22 +108,6 @@
}
}
.o_pager_value {
display: inline-block;
}
span.o_pager_value {
border-bottom: 1px solid transparent;
}
input.o_pager_value {
text-align: right;
width: 60px;
&:focus {
outline: none;
}
}
@media print {
.o_control_panel {
display: none;
@@ -28,7 +28,12 @@
</div>
</t>
<div class="o_cp_pager"/>
<div class="o_cp_pager" role="search">
<t t-if="env.config.getPagerProps">
<t t-set="pagerProps" t-value="env.config.getPagerProps()" />
<Pager t-if="pagerProps.total > 0" t-props="pagerProps" />
</t>
</div>
<t t-if="(env.config.viewSwitcherEntries or []).length">
<nav class="btn-group o_cp_switch_buttons">
@@ -0,0 +1,33 @@
/** @odoo-module **/
const { useEnv, useSubEnv } = owl.hooks;
/**
* @typedef PagerUpdateParams
* @property {number} offset
* @property {number} limit
*/
/**
* @typedef PagerProps
* @property {number} offset
* @property {number} limit
* @property {number} total
* @property {(params: PagerUpdateParams) => any} onUpdate
* @property {boolean} [isEditable]
* @property {boolean} [withAccessKey]
*/
/**
* @param {() => PagerProps} getProps
*/
export function usePager(getProps) {
const env = useEnv();
useSubEnv({
config: {
...env.config,
getPagerProps: getProps,
},
});
}
+256
View File
@@ -0,0 +1,256 @@
/** @odoo-module **/
import { Pager } from "@web/core/pager/pager";
import { registerCleanup } from "../helpers/cleanup";
import {
click,
triggerEvent,
makeDeferred,
nextTick,
getFixture,
triggerEvents,
} from "../helpers/utils";
const { Component, mount } = owl;
const { useState } = owl.hooks;
const { xml } = owl.tags;
class PagerController extends Component {
setup() {
this.state = useState({ ...this.props });
}
async updateProps(nextProps) {
Object.assign(this.state, nextProps);
await nextTick();
}
}
PagerController.template = xml`<Pager t-props="state" />`;
PagerController.components = { Pager };
async function makePager(props) {
const pager = await mount(PagerController, { target: getFixture(), props });
registerCleanup(() => {
pager.destroy();
});
return pager;
}
QUnit.module("Components", () => {
QUnit.module("Pager");
QUnit.test("basic interactions", async function (assert) {
assert.expect(2);
const pager = await makePager({
offset: 0,
limit: 4,
total: 10,
onUpdate(data) {
pager.updateProps(data);
},
});
assert.strictEqual(
pager.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"1-4",
"currentMinimum should be set to 1"
);
await click(pager.el.querySelector(`.o_pager button.o_pager_next`));
assert.strictEqual(
pager.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"5-8",
"currentMinimum should now be 5"
);
});
QUnit.test("edit the pager", async function (assert) {
assert.expect(4);
const pager = await makePager({
offset: 0,
limit: 4,
total: 10,
onUpdate(data) {
pager.updateProps(data);
},
});
await click(pager.el, ".o_pager_value");
assert.containsOnce(pager, "input", "the pager should contain an input");
assert.strictEqual(
pager.el.querySelector(`.o_pager_counter .o_pager_value`).value,
"1-4",
"the input should have correct value"
);
// change the limit
const input = pager.el.querySelector(`.o_pager_counter input.o_pager_value`);
input.value = "1-6";
await triggerEvents(input, null, ["change", "blur"]);
assert.containsNone(pager, "input", "the pager should not contain an input anymore");
assert.strictEqual(
pager.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"1-6",
"the limit should have been updated"
);
});
QUnit.test("keydown on pager with same value", async function (assert) {
assert.expect(7);
const pager = await makePager({
offset: 0,
limit: 4,
total: 10,
onUpdate() {
assert.step("pager-changed");
},
});
// Enter edit mode
await click(pager.el, ".o_pager_value");
assert.containsOnce(pager.el, "input");
assert.strictEqual(pager.el.querySelector(`.o_pager_counter .o_pager_value`).value, "1-4");
assert.verifySteps([]);
// Exit edit mode
await triggerEvent(pager.el, "input", "keydown", { key: "Enter" });
assert.containsNone(pager.el, "input");
assert.strictEqual(
pager.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"1-4"
);
assert.verifySteps(["pager-changed"]);
});
QUnit.test("pager value formatting", async function (assert) {
assert.expect(8);
const pager = await makePager({
offset: 0,
limit: 4,
total: 10,
onUpdate(data) {
pager.updateProps(data);
},
});
assert.strictEqual(
pager.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"1-4",
"Initial value should be correct"
);
async function inputAndAssert(inputValue, expected, reason) {
await click(pager.el.querySelector(`.o_pager_counter .o_pager_value`));
const inputEl = pager.el.querySelector(`.o_pager_counter input.o_pager_value`);
inputEl.value = inputValue;
await triggerEvents(inputEl, null, ["change", "blur"]);
assert.strictEqual(
pager.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
expected,
`Pager value should be "${expected}" when given "${inputValue}": ${reason}`
);
}
await inputAndAssert("4-4", "4", "values are squashed when minimum = maximum");
await inputAndAssert("1-11", "1-10", "maximum is floored to total when out of range");
await inputAndAssert("20-15", "10", "combination of the 2 assertions above");
await inputAndAssert("6-5", "10", "fallback to previous value when minimum > maximum");
await inputAndAssert(
"definitelyValidNumber",
"10",
"fallback to previous value if not a number"
);
await inputAndAssert(
" 1 , 2 ",
"1-2",
"value is normalized and accepts several separators"
);
await inputAndAssert("3 8", "3-8", "value accepts whitespace(s) as a separator");
});
QUnit.test("pager disabling", async function (assert) {
assert.expect(9);
const reloadPromise = makeDeferred();
const pager = await makePager({
offset: 0,
limit: 4,
total: 10,
// The goal here is to test the reactivity of the pager; in a
// typical views, we disable the pager after switching page
// to avoid switching twice with the same action (double click).
async onUpdate(data) {
// 1. Simulate a (long) server action
await reloadPromise;
// 2. Update the view with loaded data
pager.updateProps(data);
},
});
const pagerButtons = pager.el.querySelectorAll("button");
// Click twice
await click(pager.el.querySelector(`.o_pager button.o_pager_next`));
await click(pager.el.querySelector(`.o_pager button.o_pager_next`));
// Try to edit the pager value
await click(pager.el, ".o_pager_value");
assert.strictEqual(pagerButtons.length, 2, "the two buttons should be displayed");
assert.ok(pagerButtons[0].disabled, "'previous' is disabled");
assert.ok(pagerButtons[1].disabled, "'next' is disabled");
assert.strictEqual(
pager.el.querySelector(".o_pager_value").tagName,
"SPAN",
"pager edition is prevented"
);
// Server action is done
reloadPromise.resolve();
await nextTick();
assert.strictEqual(pagerButtons.length, 2, "the two buttons should be displayed");
assert.notOk(pagerButtons[0].disabled, "'previous' is enabled");
assert.notOk(pagerButtons[1].disabled, "'next' is enabled");
assert.strictEqual(
pager.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"5-8",
"value has been updated"
);
await click(pager.el, ".o_pager_value");
assert.strictEqual(
pager.el.querySelector(".o_pager_value").tagName,
"INPUT",
"pager edition is re-enabled"
);
});
QUnit.test("input interaction", async function (assert) {
const pager = await makePager({
offset: 0,
limit: 4,
total: 10,
onUpdate(data) {
pager.updateProps(data);
},
});
await click(pager.el, ".o_pager_value");
assert.containsOnce(pager, "input", "the pager should contain an input");
assert.strictEqual(
pager.el.querySelector("input"),
document.activeElement,
"pager input is focused"
);
await triggerEvent(pager.el, null, "mousedown");
assert.containsNone(pager, "input", "the pager should not contain an input");
});
});
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { click } from "@web/../tests/helpers/utils";
import { click, nextTick } from "@web/../tests/helpers/utils";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { makeWithSearch, setupControlPanelServiceRegistry } from "./helpers";
@@ -108,4 +108,31 @@ QUnit.module("Search", (hooks) => {
await click(views[1]);
assert.verifySteps(["kanban"]);
});
QUnit.test("pager", async (assert) => {
let pagerInfo = {
offset: 0,
limit: 10,
total: 50,
onUpdate: () => {},
};
const controlPanel = await makeWithSearch({
serverData,
resModel: "foo",
Component: ControlPanel,
config: {
getPagerProps() {
return pagerInfo;
},
},
searchMenuTypes: [],
});
assert.containsOnce(controlPanel.el, ".o_pager");
pagerInfo.total = 0;
controlPanel.render();
await nextTick();
assert.containsNone(controlPanel.el, ".o_pager");
});
});
+1 -54
View File
@@ -10,7 +10,7 @@ import { viewService } from "@web/views/view_service";
import { actionService } from "@web/webclient/actions/action_service";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { click, getFixture, mouseEnter, triggerEvent } from "../helpers/utils";
import { click, getFixture, mouseEnter, triggerEvent, triggerEvents } from "../helpers/utils";
const serviceRegistry = registry.category("services");
const favoriteMenuRegistry = registry.category("favoriteMenu");
@@ -252,56 +252,3 @@ export const switchView = async (el, viewType) => {
// const dropdown = findItem(el, `.o_cp_action_menus button`, menuFinder);
// await click(dropdown);
// }
/////////////////////////////////////
// Pager
/////////////////////////////////////
// /**
// * @param {EventTarget} el
// * @returns {Promise}
// */
// export async function pagerPrevious(el) {
// await click(getNode(el).querySelector(`.o_pager button.o_pager_previous`));
// }
// /**
// * @param {EventTarget} el
// * @returns {Promise}
// */
// export async function pagerNext(el) {
// await click(getNode(el).querySelector(`.o_pager button.o_pager_next`));
// }
// /**
// * @param {EventTarget} el
// * @returns {string}
// */
// export function getPagerValue(el) {
// const pagerValue = getNode(el).querySelector(`.o_pager_counter .o_pager_value`);
// switch (pagerValue.tagName) {
// case 'INPUT':
// return pagerValue.value;
// case 'SPAN':
// return pagerValue.innerText.trim();
// }
// }
// /**
// * @param {EventTarget} el
// * @returns {string}
// */
// export function getPagerSize(el) {
// return getNode(el).querySelector(`.o_pager_counter span.o_pager_limit`).innerText.trim();
// }
// /**
// * @param {EventTarget} el
// * @param {string} value
// * @returns {Promise}
// */
// export async function setPagerValue(el, value) {
// let pagerValue = getNode(el).querySelector(`.o_pager_counter .o_pager_value`);
// if (pagerValue.tagName === 'SPAN') {
// await click(pagerValue);
// }
// pagerValue = getNode(el).querySelector(`.o_pager_counter input.o_pager_value`);
// if (!pagerValue) {
// throw new Error("Pager value is being edited and cannot be changed.");
// }
// await editAndTrigger(pagerValue, value, ['change', 'blur']);
// }
@@ -0,0 +1,116 @@
/** @odoo-module **/
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { usePager } from "@web/search/pager_hook";
import { click } from "../helpers/utils";
import { makeWithSearch, setupControlPanelServiceRegistry } from "./helpers";
const { Component } = owl;
const { useState } = owl.hooks;
const { xml } = owl.tags;
let serverData;
QUnit.module("Search", (hooks) => {
hooks.beforeEach(async () => {
serverData = {
models: {
foo: {
fields: {},
},
},
views: {
"foo,false,search": `<search/>`,
},
};
setupControlPanelServiceRegistry();
});
QUnit.module("usePager");
QUnit.test("pager is correctly displayed", async (assert) => {
class TestComponent extends Component {
setup() {
usePager(() => ({
offset: 0,
limit: 10,
total: 50,
onUpdate: () => {},
}));
}
}
TestComponent.components = { ControlPanel };
TestComponent.template = xml`<ControlPanel />`;
const comp = await makeWithSearch({
serverData,
resModel: "foo",
Component: TestComponent,
searchMenuTypes: [],
});
assert.containsOnce(comp, ".o_pager");
assert.strictEqual(
comp.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"1-10"
);
assert.strictEqual(
comp.el.querySelector(`.o_pager_counter span.o_pager_limit`).textContent.trim(),
"50"
);
});
QUnit.test("pager is correctly updated", async (assert) => {
class TestComponent extends Component {
setup() {
this.state = useState({ offset: 0, limit: 10 });
usePager(() => ({
offset: this.state.offset,
limit: this.state.limit,
total: 50,
onUpdate: (newState) => {
Object.assign(this.state, newState);
},
}));
}
}
TestComponent.components = { ControlPanel };
TestComponent.template = xml`<ControlPanel />`;
const comp = await makeWithSearch({
serverData,
resModel: "foo",
Component: TestComponent,
searchMenuTypes: [],
});
assert.containsOnce(comp, ".o_pager");
assert.strictEqual(
comp.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"1-10"
);
assert.strictEqual(
comp.el.querySelector(`.o_pager_counter span.o_pager_limit`).textContent.trim(),
"50"
);
assert.deepEqual(comp.state, {
offset: 0,
limit: 10,
});
await click(comp.el.querySelector(`.o_pager button.o_pager_next`));
assert.containsOnce(comp, ".o_pager");
assert.strictEqual(
comp.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"11-20"
);
assert.strictEqual(
comp.el.querySelector(`.o_pager_counter span.o_pager_limit`).textContent.trim(),
"50"
);
assert.deepEqual(comp.state, {
offset: 10,
limit: 10,
});
});
});