[IMP] web: datepicker WOWL

This commit adapts the "old" Owl Datepicker to work with the new Owl
web client. The behaviors have been mostly preserved (i.e. working
with the bootstrap datepicker) and simply adapted to make use of the
new services and registries.

The previous datepicker template and tests have been marked as "legacy".

Part-of: odoo/odoo#73311
This commit is contained in:
Julien Mougenot
2021-09-07 10:04:34 +00:00
committed by Aaron Bohy
parent a19ad0fe24
commit 42cbcad9c8
6 changed files with 724 additions and 4 deletions
@@ -0,0 +1,287 @@
/** @odoo-module **/
import { localization } from "@web/core/l10n/localization";
import { registry } from "@web/core/registry";
import { useAutofocus } from "@web/core/utils/hooks";
const { Component, hooks } = owl;
const { DateTime } = luxon;
const { useExternalListener, useRef, useState } = hooks;
const formatters = registry.category("formatters");
const parsers = registry.category("parsers");
let datePickerId = 0;
/**
* @param {DateTime} date
* @returns {moment}
*/
const luxonDateToMomentDate = (date) => {
return window.moment(String(date));
};
/**
* @param {string} format
* @returns {string}
*/
const luxonFormatToMomentFormat = (format) => {
return format.replace(/[dy]/g, (x) => x.toUpperCase());
};
/**
* Date picker
*
* This component exposes the API of the tempusdominus datepicker library.
* As such, its template is a simple input that will open the TD datepicker
* when clicked on. The component will also synchronize any user-input value
* with the library widget and vice-versa.
*
* Note that all props given to this component will be passed as arguments to
* instantiate the picker widget. Also any luxon date is automatically
* stringified since tempusdominus only works with moment objects.
* @extends Component
*/
export class DatePicker extends Component {
setup() {
this.inputRef = useRef("input");
this.state = useState({ warning: false });
this.datePickerId = `o_datepicker_${datePickerId++}`;
this.initFormat();
this.setDate(this.props);
useAutofocus();
useExternalListener(window, "scroll", this.onWindowScroll);
}
mounted() {
window.$(this.el).on("show.datetimepicker", () => this.inputRef.el.select());
window.$(this.el).on("hide.datetimepicker", () => this.onDateChange());
window.$(this.el).on("error.datetimepicker", () => false); // Ignores datepicker errors
this.bootstrapDateTimePicker(this.props);
this.updateInput();
}
willUpdateProps(nextProps) {
const pickerParams = {};
for (const prop in nextProps) {
if (this.props[prop] !== nextProps[prop]) {
pickerParams[prop] = nextProps[prop];
if (prop === "date") {
this.setDate(nextProps);
this.updateInput();
}
}
}
this.bootstrapDateTimePicker(pickerParams);
}
willUnmount() {
window.$(this.el).off(); // Removes all jQuery events
this.bootstrapDateTimePicker("destroy");
}
//---------------------------------------------------------------------
// Getters
//---------------------------------------------------------------------
get options() {
return {
// Fallback to default localization format in `core/l10n/dates.js`.
format: this.props.format,
locale: this.props.locale || this.date.locale,
timezone: this.isLocal,
};
}
//---------------------------------------------------------------------
// Protected
//---------------------------------------------------------------------
/**
* Initialises formatting and parsing parameters
*/
initFormat() {
this.defaultFormat = localization.dateFormat;
this.format = formatters.get("date");
this.parse = parsers.get("date");
this.isLocal = false;
}
/**
* Sets the current date value. If a locale is provided, the given date
* will first be set in that locale.
* @param {object} params
* @param {DateTime} params.date
* @param {string} [params.locale]
*/
setDate({ date, locale }) {
this.date = locale ? date.setLocale(locale) : date;
}
/**
* Updates the input element with the current formatted date value.
*/
updateInput() {
try {
this.inputRef.el.value = this.format(this.date, this.options);
} catch (err) {
// Do nothing
}
}
//---------------------------------------------------------------------
// Bootstrap datepicker
//---------------------------------------------------------------------
/**
* Handles bootstrap datetimepicker calls.
* @param {string | Object} commandOrParams
*/
bootstrapDateTimePicker(commandOrParams) {
if (typeof commandOrParams === "object") {
const format = luxonFormatToMomentFormat(this.props.format || this.defaultFormat);
const params = { ...commandOrParams, format };
for (const prop in params) {
if (params[prop] instanceof DateTime) {
const luxonDate = params[prop];
const momentDate = luxonDateToMomentDate(luxonDate);
if (momentDate.locale() !== luxonDate.locale) {
momentDate.locale(params.locale);
}
params[prop] = momentDate;
}
}
commandOrParams = params;
}
window.$(this.el).datetimepicker(commandOrParams);
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
onInputClick() {
this.bootstrapDateTimePicker("toggle");
}
/**
* Called either when the input value has changed or when the boostrap
* datepicker is closed. The actual "datetime-changed" emitted by the
* component is only triggered if the date value has changed.
*/
onDateChange() {
try {
const date = this.parse(this.inputRef.el.value, this.options);
if (!date.equals(this.props.date)) {
this.state.warning = date > DateTime.local();
this.trigger("datetime-changed", { date });
}
} catch (err) {
// Reset to default (= given) date.
this.updateInput();
}
}
/**
* @param {Event} ev
*/
onWindowScroll(ev) {
if (ev.target !== this.inputRef.el) {
this.bootstrapDateTimePicker("hide");
}
}
}
DatePicker.defaultProps = {
calendarWeeks: true,
icons: {
clear: "fa fa-delete",
close: "fa fa-check primary",
date: "fa fa-calendar",
down: "fa fa-chevron-down",
next: "fa fa-chevron-right",
previous: "fa fa-chevron-left",
time: "fa fa-clock-o",
today: "fa fa-calendar-check-o",
up: "fa fa-chevron-up",
},
maxDate: DateTime.fromObject({ year: 9999, month: 12, day: 31 }),
minDate: DateTime.fromObject({ year: 1000 }),
useCurrent: false,
widgetParent: "body",
};
DatePicker.props = {
// Components props
date: DateTime,
warn_future: { type: Boolean, optional: true },
// Bootstrap datepicker options
buttons: {
type: Object,
shape: {
showClear: Boolean,
showClose: Boolean,
showToday: Boolean,
},
optional: true,
},
calendarWeeks: Boolean,
format: { type: String, optional: true },
icons: {
type: Object,
shape: {
clear: String,
close: String,
date: String,
down: String,
next: String,
previous: String,
time: String,
today: String,
up: String,
},
},
keyBinds: { validate: (kb) => typeof kb === "object" || kb === null, optional: true },
locale: { type: String, optional: true },
maxDate: DateTime,
minDate: DateTime,
readonly: { type: Boolean, optional: true },
useCurrent: Boolean,
widgetParent: String,
};
DatePicker.template = "web.DatePicker";
/**
* Date/time picker
*
* Similar to the DatePicker component, adding the handling of more specific
* time values: hour-minute-second.
*
* Once again, refer to the tempusdominus documentation for implementation
* details.
* @extends DatePicker
*/
export class DateTimePicker extends DatePicker {
/**
* @override
*/
initFormat() {
this.defaultFormat = localization.dateTimeFormat;
this.format = formatters.get("datetime");
this.parse = parsers.get("datetime");
this.isLocal = true;
}
}
DateTimePicker.defaultProps = {
...DatePicker.defaultProps,
buttons: {
showClear: false,
showClose: true,
showToday: false,
},
};
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DatePicker" owl="1">
<div class="o_datepicker" aria-atomic="true" t-att-id="datePickerId" data-target-input="nearest">
<input type="text"
class="o_datepicker_input o_input datetimepicker-input"
autofocus=""
t-att-name="props.name"
t-att-placeholder="props.placeholder"
t-attf-data-target="#{{ datePickerId }}"
t-att-readonly="props.readonly"
t-ref="input"
t-on-change="onDateChange"
t-on-click="onInputClick"
/>
<span t-if="props.warn_future and state.warning" class="fa fa-exclamation-triangle text-danger o_tz_warning o_datepicker_warning">
<t>This date is on the future. Make sure it is what you expected.</t>
</span>
<span class="o_datepicker_button" />
</div>
</t>
</templates>
@@ -221,7 +221,7 @@ odoo.define('web.DatePickerOwl', function (require) {
useCurrent: Boolean,
widgetParent: String,
};
DatePicker.template = 'web.DatePicker';
DatePicker.template = "web.Legacy.DatePicker";
/**
* Date/time picker
+1 -1
View File
@@ -125,7 +125,7 @@
</span>
</t>
<t t-name="web.DatePicker" owl="1">
<t t-name="web.Legacy.DatePicker" owl="1">
<div class="o_datepicker" aria-atomic="true" t-att-id="datePickerId" data-target-input="nearest">
<input type="text" class="o_datepicker_input o_input datetimepicker-input" autofocus=""
t-att-name="props.name"
@@ -0,0 +1,408 @@
/** @odoo-module **/
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { registerCleanup } from "../helpers/cleanup";
import { makeTestEnv } from "../helpers/mock_env";
import { makeFakeLocalizationService } from "../helpers/mock_services";
import { click, getFixture, triggerEvent } from "../helpers/utils";
const { DateTime } = luxon;
const { Component, mount, tags } = owl;
const { xml } = tags;
const serviceRegistry = registry.category("services");
/**
* @param {typeof DatePicker} Picker
* @param {{ props: any, onDateChange: () => any }} [params={}]
* @returns {Promise<DatePicker>}
*/
const mountPicker = async (Picker, { props, onDateChange } = {}) => {
serviceRegistry
.add(
"localization",
makeFakeLocalizationService({
dateFormat: "dd/MM/yyyy",
dateTimeFormat: "dd/MM/yyyy HH:mm:ss",
})
)
.add("ui", uiService);
class Parent extends Component {}
Parent.template = xml/* xml */ `
<t t-component="props.Picker" t-props="props.props" t-on-datetime-changed="props.onDateChange" />
`;
const env = await makeTestEnv();
const target = getFixture();
const parent = await mount(Parent, { env, props: { Picker, props, onDateChange }, target });
registerCleanup(() => parent.destroy());
return parent;
};
const useFRLocale = () => {
if (!window.moment.locales().includes("fr")) {
// Mocks the FR locale if not loaded
const originalLocale = window.moment.locale();
window.moment.defineLocale("fr", {
months: "janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split(
"_"
),
monthsShort: "janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split(
"_"
),
code: "fr",
monthsParseExact: true,
week: { dow: 1, doy: 4 },
});
// Moment automatically assigns newly defined locales.
window.moment.locale(originalLocale);
registerCleanup(() => window.moment.updateLocale("fr", null));
}
return "fr";
};
QUnit.module("Components", () => {
QUnit.module("DatePicker");
QUnit.test("basic rendering", async function (assert) {
assert.expect(8);
const picker = await mountPicker(DatePicker, {
props: {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
},
});
assert.containsOnce(picker, "input.o_input.o_datepicker_input");
assert.containsOnce(picker, "span.o_datepicker_button");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget");
const input = picker.el.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997", "Value should be the one given");
assert.strictEqual(
input.dataset.target,
`#${picker.el.id}`,
"DatePicker id should match its input target"
);
await click(input);
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget .timepicker");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"01/09/1997",
"Datepicker should have set the correct day"
);
});
QUnit.test("pick a date", async function (assert) {
assert.expect(5);
const picker = await mountPicker(DatePicker, {
props: {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
},
onDateChange: (ev) => {
assert.step("datetime-changed");
assert.strictEqual(
ev.detail.date.toFormat("dd/MM/yyyy"),
"08/02/1997",
"Event should transmit the correct date"
);
},
});
const input = picker.el.querySelector(".o_datepicker_input");
await click(input);
await click(document.querySelector(".datepicker th.next")); // next month
assert.verifySteps([]);
await click(document.querySelectorAll(".datepicker table td")[15]); // previous day
assert.strictEqual(input.value, "08/02/1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date with locale", async function (assert) {
assert.expect(5);
const picker = await mountPicker(DatePicker, {
props: {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "dd MMM, yyyy",
locale: useFRLocale(),
},
onDateChange: (ev) => {
assert.step("datetime-changed");
assert.strictEqual(
ev.detail.date.toFormat("dd/MM/yyyy"),
"01/09/1997",
"Event should transmit the correct date"
);
},
});
const input = picker.el.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "09 janv., 1997");
await click(input);
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelector(".datepicker .day")); // first day
assert.strictEqual(input.value, "01 sept., 1997");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("enter a date value", async function (assert) {
assert.expect(5);
const picker = await mountPicker(DatePicker, {
props: {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
},
onDateChange: (ev) => {
assert.step("datetime-changed");
assert.strictEqual(
ev.detail.date.toFormat("dd/MM/yyyy"),
"08/02/1997",
"Event should transmit the correct date"
);
},
});
const input = picker.el.querySelector(".o_datepicker_input");
assert.verifySteps([]);
input.value = "08/02/1997";
await triggerEvent(picker.el, ".o_datepicker_input", "change");
assert.verifySteps(["datetime-changed"]);
await click(input);
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"02/08/1997",
"Datepicker should have set the correct day"
);
});
QUnit.test("Date format is correctly set", async function (assert) {
assert.expect(2);
const picker = await mountPicker(DatePicker, {
props: {
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
format: "yyyy/MM/dd",
},
});
const input = picker.el.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "1997/01/09");
// Forces an update to assert that the registered format is the correct one
await click(input);
assert.strictEqual(input.value, "1997/01/09");
});
QUnit.module("DateTimePicker");
QUnit.test("basic rendering", async function (assert) {
assert.expect(11);
const picker = await mountPicker(DateTimePicker, {
props: {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
},
});
assert.containsOnce(picker, "input.o_input.o_datepicker_input");
assert.containsOnce(picker, "span.o_datepicker_button");
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget");
const input = picker.el.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09/01/1997 12:30:01", "Value should be the one given");
assert.strictEqual(
input.dataset.target,
`#${picker.el.id}`,
"DateTimePicker id should match its input target"
);
await click(input);
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker");
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .timepicker");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"01/09/1997",
"Datepicker should have set the correct day"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-hour").innerText.trim(),
"12",
"Datepicker should have set the correct hour"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-minute").innerText.trim(),
"30",
"Datepicker should have set the correct minute"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-second").innerText.trim(),
"01",
"Datepicker should have set the correct second"
);
});
QUnit.test("pick a date and time", async function (assert) {
assert.expect(5);
const picker = await mountPicker(DateTimePicker, {
props: { date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss") },
onDateChange: (ev) => {
assert.step("datetime-changed");
assert.strictEqual(
ev.detail.date.toFormat("dd/MM/yyyy HH:mm:ss"),
"08/02/1997 15:45:05",
"Event should transmit the correct date"
);
},
});
const input = picker.el.querySelector("input.o_input.o_datepicker_input");
await click(input);
await click(document.querySelector(".datepicker th.next")); // February
await click(document.querySelectorAll(".datepicker table td")[15]); // 08
await click(document.querySelector('a[title="Select Time"]'));
await click(document.querySelector(".timepicker .timepicker-hour"));
await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h
await click(document.querySelector(".timepicker .timepicker-minute"));
await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m
await click(document.querySelector(".timepicker .timepicker-second"));
assert.verifySteps([]);
await click(document.querySelectorAll(".timepicker .second")[1]); // 05s
assert.strictEqual(input.value, "08/02/1997 15:45:05");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("pick a date and time with locale", async function (assert) {
assert.expect(6);
const picker = await mountPicker(DateTimePicker, {
props: {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "dd MMM, yyyy HH:mm:ss",
locale: useFRLocale(),
},
onDateChange: (ev) => {
assert.step("datetime-changed");
assert.strictEqual(
ev.detail.date.toFormat("dd/MM/yyyy HH:mm:ss"),
"01/09/1997 15:45:05",
"Event should transmit the correct date"
);
},
});
const input = picker.el.querySelector("input.o_input.o_datepicker_input");
assert.strictEqual(input.value, "09 janv., 1997 12:30:01");
await click(input);
await click(document.querySelector(".datepicker .picker-switch")); // month picker
await click(document.querySelectorAll(".datepicker .month")[8]); // september
await click(document.querySelector(".datepicker .day")); // first day
await click(document.querySelector('a[title="Select Time"]'));
await click(document.querySelector(".timepicker .timepicker-hour"));
await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h
await click(document.querySelector(".timepicker .timepicker-minute"));
await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m
await click(document.querySelector(".timepicker .timepicker-second"));
assert.verifySteps([]);
await click(document.querySelectorAll(".timepicker .second")[1]); // 05s
assert.strictEqual(input.value, "01 sept., 1997 15:45:05");
assert.verifySteps(["datetime-changed"]);
});
QUnit.test("enter a datetime value", async function (assert) {
assert.expect(9);
const picker = await mountPicker(DateTimePicker, {
props: { date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss") },
onDateChange: (ev) => {
assert.step("datetime-changed");
assert.strictEqual(
ev.detail.date.toFormat("dd/MM/yyyy HH:mm:ss"),
"08/02/1997 15:45:05",
"Event should transmit the correct date"
);
},
});
const input = picker.el.querySelector(".o_datepicker_input");
assert.verifySteps([]);
input.value = "08/02/1997 15:45:05";
await triggerEvent(picker.el, ".o_datepicker_input", "change");
assert.verifySteps(["datetime-changed"]);
await click(input);
assert.strictEqual(input.value, "08/02/1997 15:45:05");
assert.strictEqual(
document.querySelector(".datepicker .day.active").dataset.day,
"02/08/1997",
"Datepicker should have set the correct day"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-hour").innerText.trim(),
"15",
"Datepicker should have set the correct hour"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-minute").innerText.trim(),
"45",
"Datepicker should have set the correct minute"
);
assert.strictEqual(
document.querySelector(".timepicker .timepicker-second").innerText.trim(),
"05",
"Datepicker should have set the correct second"
);
});
QUnit.test("Date time format is correctly set", async function (assert) {
assert.expect(2);
const picker = await mountPicker(DateTimePicker, {
props: {
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
format: "HH:mm:ss yyyy/MM/dd",
},
});
const input = picker.el.querySelector(".o_datepicker_input");
assert.strictEqual(input.value, "12:30:01 1997/01/09");
// Forces an update to assert that the registered format is the correct one
await click(input);
assert.strictEqual(input.value, "12:30:01 1997/01/09");
});
});
@@ -9,7 +9,7 @@ odoo.define('web.datepicker_tests', function (require) {
QUnit.module('Components', {}, function () {
QUnit.module('DatePicker');
QUnit.module('DatePicker (legacy)');
QUnit.test("basic rendering", async function (assert) {
assert.expect(8);
@@ -164,7 +164,7 @@ odoo.define('web.datepicker_tests', function (require) {
testUtils.mock.unpatch(time);
});
QUnit.module('DateTimePicker');
QUnit.module('DateTimePicker (legacy)');
QUnit.test("basic rendering", async function (assert) {
assert.expect(11);