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 {