[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) <jum@odoo.com>
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Julien Mougenot
2022-05-04 08:07:00 +02:00
parent f0fe59dcf7
commit 88aada39e9
3 changed files with 154 additions and 46 deletions
@@ -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;
}
}
@@ -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"
/>
<span t-if="props.warn_future and state.warning" class="fa fa-exclamation-triangle text-danger o_tz_warning o_datepicker_warning">
@@ -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<DatePicker>}
*/
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 */ `
<t t-component="props.Picker" t-props="props.props"/>
<t t-component="props.Picker" t-props="state" onDateTimeChanged.bind="onDateChange" />
`;
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 {