From 88aada39e97fa38e651a85966d8c48fd4fbcd3c6 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Mon, 11 Apr 2022 15:24:35 +0000 Subject: [PATCH] [FIX] web: Datepicker uses static date format The current datepicker implementation uses Luxon to send and receive dates from the client and Moment to create and communicate with the bootstrap datepicker. Before this commit: the current database date/time format was used to convert dates from one system to the other. However there are inconsistencies in notation for some formats, for which the conversion could not be made properly. Now: a static format is used to translate dates and the actual database format is applied only after both systems have been notified of a date change. Tasks 2794506, 2797949 closes odoo/odoo#90422 X-original-commit: 174bc1dfa7b2894f35c288373f5130ec2992a8e8 Signed-off-by: Julien Mougenot (jum) Signed-off-by: Aaron Bohy (aab) --- .../static/src/core/datepicker/datepicker.js | 112 +++++++++++++----- .../static/src/core/datepicker/datepicker.xml | 2 +- .../web/static/tests/core/datepicker_tests.js | 86 ++++++++++++-- 3 files changed, 154 insertions(+), 46 deletions(-) diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js index 7c7b23e6284..e24b2e5f44a 100644 --- a/addons/web/static/src/core/datepicker/datepicker.js +++ b/addons/web/static/src/core/datepicker/datepicker.js @@ -36,6 +36,18 @@ const luxonFormatToMomentFormat = (format) => { return format.replace(/[dy]/g, (x) => x.toUpperCase()); }; +/** + * @param {string} format + * @returns {boolean} + */ +const isValidStaticFormat = (format) => { + try { + return /^[\d\s/:-]+$/.test(DateTime.local().toFormat(format)); + } catch (_err) { + return false; + } +}; + /** * Date picker * @@ -56,9 +68,13 @@ export class DatePicker extends Component { this.state = useState({ warning: false }); this.datePickerId = `o_datepicker_${datePickerId++}`; + // Manually keep track of the "open" state to write the date in the + // static format just before bootstrap parses it. + this.datePickerShown = false; this.initFormat(); this.setDate(this.props); + this.setFormat(this.props); useAutofocus(); useExternalListener(window, "scroll", this.onWindowScroll); @@ -69,25 +85,40 @@ export class DatePicker extends Component { } onMounted() { - window.$(this.root.el).on("show.datetimepicker", () => this.inputRef.el.select()); - window.$(this.root.el).on("hide.datetimepicker", () => this.onDateChange()); - window.$(this.root.el).on("error.datetimepicker", () => false); // Ignores datepicker errors - this.bootstrapDateTimePicker(this.props); this.updateInput(); + + window.$(this.root.el).on("show.datetimepicker", () => { + this.datePickerShown = true; + this.inputRef.el.select(); + }); + window.$(this.root.el).on("hide.datetimepicker", () => { + this.datePickerShown = false; + this.onDateChange({ useStatic: true }); + }); + window.$(this.root.el).on("error.datetimepicker", () => false); } onWillUpdateProps(nextProps) { const pickerParams = {}; + let shouldUpdateInput = false; for (const prop in nextProps) { - if (this.props[prop] !== nextProps[prop]) { + const prev = this.props[prop]; + const next = nextProps[prop]; + if ((prev instanceof DateTime && !prev.equals(next)) || prev !== next) { pickerParams[prop] = nextProps[prop]; if (prop === "date") { this.setDate(nextProps); - this.updateInput(); + shouldUpdateInput = true; + } else if (prop === "format") { + this.setFormat(nextProps); + shouldUpdateInput = true; } } } + if (shouldUpdateInput) { + this.updateInput(); + } this.bootstrapDateTimePicker(pickerParams); } @@ -98,36 +129,33 @@ export class DatePicker extends Component { } //--------------------------------------------------------------------- - // Getters + // Protected //--------------------------------------------------------------------- - get options() { + getOptions(useStatic = false) { return { - // Fallback to default localization format in `core/l10n/dates.js`. - format: this.props.format, - locale: this.props.locale || this.date.locale, + format: + !useStatic || isValidStaticFormat(this.format) ? this.format : this.staticFormat, + 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.staticFormat = "yyyy/MM/dd"; + this.formatValue = formatters.get("date"); + this.parseValue = 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 {Object} params * @param {DateTime} params.date * @param {string} [params.locale] */ @@ -136,11 +164,23 @@ export class DatePicker extends Component { } /** - * Updates the input element with the current formatted date value. + * Sets the current format. + * @param {Object} params + * @param {string} [params.format] */ - updateInput() { + setFormat({ format }) { + // Fallback to default localization format in `@web/core/l10n/dates.js`. + this.format = format || this.defaultFormat; + } + + /** + * Updates the input element with the current formatted date value. + * @param {Object} [params={}] + * @param {boolean} [params.useStatic] + */ + updateInput({ useStatic } = {}) { try { - this.inputRef.el.value = this.format(this.date, this.options); + this.inputRef.el.value = this.formatValue(this.date, this.getOptions(useStatic)); } catch (_err) { // Do nothing } @@ -156,8 +196,8 @@ export class DatePicker extends Component { */ bootstrapDateTimePicker(commandOrParams) { if (typeof commandOrParams === "object") { - const format = luxonFormatToMomentFormat(this.props.format || this.defaultFormat); - const params = { ...commandOrParams, format }; + const format = isValidStaticFormat(this.format) ? this.format : this.staticFormat; + const params = { ...commandOrParams, format: luxonFormatToMomentFormat(format) }; if (!params.locale && commandOrParams.date) { params.locale = commandOrParams.date.locale; } @@ -170,7 +210,7 @@ export class DatePicker extends Component { } commandOrParams = params; } - window.$(this.root.el).datetimepicker(commandOrParams); + return window.$(this.root.el).datetimepicker(commandOrParams); } //--------------------------------------------------------------------- @@ -178,6 +218,9 @@ export class DatePicker extends Component { //--------------------------------------------------------------------- onInputClick() { + if (!this.datePickerShown) { + this.updateInput({ useStatic: true }); + } this.bootstrapDateTimePicker("toggle"); } @@ -185,17 +228,21 @@ export class DatePicker extends Component { * Called either when the input value has changed or when the boostrap * datepicker is closed. The onDateTimeChanged prop is only called if the * date value has changed. + * @param {Object} [params={}] + * @param {boolean} [params.useStatic] */ - onDateChange() { + onDateChange({ useStatic } = {}) { + let date; try { - const date = this.parse(this.inputRef.el.value, this.options); - if (!date.equals(this.props.date)) { - this.state.warning = date > DateTime.local(); - this.props.onDateTimeChanged(date); - } + date = this.parseValue(this.inputRef.el.value, this.getOptions(useStatic)); } catch (_err) { // Reset to default (= given) date. + } + if (!date || date.equals(this.date)) { this.updateInput(); + } else { + this.state.warning = date > DateTime.local(); + this.props.onDateTimeChanged(date); } } @@ -285,8 +332,9 @@ export class DateTimePicker extends DatePicker { */ initFormat() { this.defaultFormat = localization.dateTimeFormat; - this.format = formatters.get("datetime"); - this.parse = parsers.get("datetime"); + this.staticFormat = "yyyy/MM/dd HH:mm:ss"; + this.formatValue = formatters.get("datetime"); + this.parseValue = parsers.get("datetime"); this.isLocal = true; } } diff --git a/addons/web/static/src/core/datepicker/datepicker.xml b/addons/web/static/src/core/datepicker/datepicker.xml index e8c9cbb5a43..e3c5af2af6f 100644 --- a/addons/web/static/src/core/datepicker/datepicker.xml +++ b/addons/web/static/src/core/datepicker/datepicker.xml @@ -11,7 +11,7 @@ t-attf-data-target="#{{ datePickerId }}" t-att-readonly="props.readonly" t-ref="input" - t-on-change="onDateChange" + t-on-change="() => this.onDateChange()" t-on-click="onInputClick" /> diff --git a/addons/web/static/tests/core/datepicker_tests.js b/addons/web/static/tests/core/datepicker_tests.js index 986f081cfe2..ce3d76cb1b5 100644 --- a/addons/web/static/tests/core/datepicker_tests.js +++ b/addons/web/static/tests/core/datepicker_tests.js @@ -1,20 +1,21 @@ /** @odoo-module **/ -import CustomFilterItem from "web.CustomFilterItem"; -import ActionModel from "web.ActionModel"; +import { applyFilter, toggleMenu } from "@web/../tests/search/helpers"; import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker"; import { registry } from "@web/core/registry"; import { uiService } from "@web/core/ui/ui_service"; -import { editSelect } from "web.test_utils_fields"; +import ActionModel from "web.ActionModel"; +import CustomFilterItem from "web.CustomFilterItem"; import { createComponent } from "web.test_utils"; +import { editSelect } from "web.test_utils_fields"; import { registerCleanup } from "../helpers/cleanup"; import { makeTestEnv } from "../helpers/mock_env"; import { makeFakeLocalizationService } from "../helpers/mock_services"; import { click, getFixture, mount, triggerEvent } from "../helpers/utils"; -import { applyFilter, toggleMenu } from "@web/../tests/search/helpers"; -const { Component, xml } = owl; +const { Component, useState, xml } = owl; const { DateTime } = luxon; + const serviceRegistry = registry.category("services"); let target; @@ -22,7 +23,6 @@ let target; /** * @param {typeof DatePicker} Picker * @param {Object} props - * @param {DateTime} props.date * @returns {Promise} */ const mountPicker = async (Picker, props) => { @@ -36,17 +36,24 @@ const mountPicker = async (Picker, props) => { ) .add("ui", uiService); - class Parent extends Component {} + class Parent extends Component { + setup() { + this.state = useState(props); + } + + onDateChange(date) { + if (props.onDateTimeChanged) { + props.onDateTimeChanged(date); + } + this.state.date = date; + } + } Parent.template = xml/* xml */ ` - + `; const env = await makeTestEnv(); - if (!props.onDateTimeChanged) { - props.onDateTimeChanged = () => {}; - } - const parent = await mount(Parent, target, { env, props: { Picker, props } }); - return parent; + await mount(Parent, target, { env, props: { Picker } }); }; const useFRLocale = () => { @@ -71,6 +78,27 @@ const useFRLocale = () => { return "fr"; }; +const useNOLocale = () => { + if (!window.moment.locales().includes("nb")) { + const originalLocale = window.moment.locale(); + window.moment.defineLocale("nb", { + months: "januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split( + "_" + ), + monthsShort: "jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.".split("_"), + monthsParseExact: true, + week: { + dow: 1, // Monday is the first day of the week. + doy: 4, // The week that contains Jan 4th is the first week of the year. + }, + }); + // Moment automatically assigns newly defined locales. + window.moment.locale(originalLocale); + registerCleanup(() => window.moment.updateLocale("nb", null)); + } + return "nb"; +}; + QUnit.module("Components", ({ beforeEach }) => { beforeEach(() => { target = getFixture(); @@ -433,6 +461,38 @@ QUnit.module("Components", ({ beforeEach }) => { assert.strictEqual(input.value, "12:30:01 1997/01/09"); }); + QUnit.test("Datepicker works with norwegian locale", async (assert) => { + assert.expect(6); + + await mountPicker(DatePicker, { + date: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + format: "dd MMM, yyyy", + locale: useNOLocale(), + onDateTimeChanged(date) { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy"), + "01/04/1997", + "Event should transmit the correct date" + ); + }, + }); + + const input = target.querySelector(".o_datepicker_input"); + + assert.strictEqual(input.value, "09 apr., 1997"); + + await click(input); + + assert.strictEqual(input.value, "1997/04/09"); + + const days = [...document.querySelectorAll(".datepicker .day")]; + await click(days.find((d) => d.innerText.trim() === "1")); // first day of april + + assert.strictEqual(input.value, "01 apr., 1997"); + assert.verifySteps(["datetime-changed"]); + }); + QUnit.test("custom filter date", async function (assert) { assert.expect(3); class MockedSearchModel extends ActionModel {