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);