[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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user