From 42cbcad9c8173c1b8d23b98f2c3fb8515b0c3992 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Tue, 7 Sep 2021 06:22:33 +0000 Subject: [PATCH] [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 --- .../static/src/core/datepicker/datepicker.js | 287 ++++++++++++ .../static/src/core/datepicker/datepicker.xml | 25 ++ .../src/legacy/js/components/datepicker.js | 2 +- addons/web/static/src/legacy/xml/base.xml | 2 +- .../web/static/tests/core/datepicker_tests.js | 408 ++++++++++++++++++ .../legacy/components/datepicker_tests.js | 4 +- 6 files changed, 724 insertions(+), 4 deletions(-) create mode 100644 addons/web/static/src/core/datepicker/datepicker.js create mode 100644 addons/web/static/src/core/datepicker/datepicker.xml create mode 100644 addons/web/static/tests/core/datepicker_tests.js diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js new file mode 100644 index 00000000000..82d96f5c615 --- /dev/null +++ b/addons/web/static/src/core/datepicker/datepicker.js @@ -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, + }, +}; diff --git a/addons/web/static/src/core/datepicker/datepicker.xml b/addons/web/static/src/core/datepicker/datepicker.xml new file mode 100644 index 00000000000..b72e3a216b0 --- /dev/null +++ b/addons/web/static/src/core/datepicker/datepicker.xml @@ -0,0 +1,25 @@ + + + + + +
+ + + This date is on the future. Make sure it is what you expected. + + +
+
+ +
diff --git a/addons/web/static/src/legacy/js/components/datepicker.js b/addons/web/static/src/legacy/js/components/datepicker.js index 94ce7ece4fe..2c27a55d5f4 100644 --- a/addons/web/static/src/legacy/js/components/datepicker.js +++ b/addons/web/static/src/legacy/js/components/datepicker.js @@ -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 diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index ed071fc1583..aa1e34787d0 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -125,7 +125,7 @@ - +
any }} [params={}] + * @returns {Promise} + */ +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 */ ` + + `; + + 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"); + }); +}); diff --git a/addons/web/static/tests/legacy/components/datepicker_tests.js b/addons/web/static/tests/legacy/components/datepicker_tests.js index 78908bd911f..b55d66ec3e1 100644 --- a/addons/web/static/tests/legacy/components/datepicker_tests.js +++ b/addons/web/static/tests/legacy/components/datepicker_tests.js @@ -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);