diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js
index e24b2e5f44a..48cc42b07f9 100644
--- a/addons/web/static/src/core/datepicker/datepicker.js
+++ b/addons/web/static/src/core/datepicker/datepicker.js
@@ -1,7 +1,14 @@
/** @odoo-module **/
+import {
+ areDateEquals,
+ formatDate,
+ formatDateTime,
+ parseDate,
+ parseDateTime,
+} from "@web/core/l10n/dates";
+import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import { localization } from "@web/core/l10n/localization";
-import { registry } from "@web/core/registry";
import { useAutofocus } from "@web/core/utils/hooks";
const {
@@ -15,38 +22,47 @@ const {
} = owl;
const { DateTime } = luxon;
-const formatters = registry.category("formatters");
-const parsers = registry.category("parsers");
-
let datePickerId = 0;
/**
* @param {DateTime} date
* @returns {moment}
*/
-const luxonDateToMomentDate = (date) => {
+function luxonDateToMomentDate(date) {
return window.moment(String(date));
-};
+}
/**
* @param {string} format
* @returns {string}
*/
-const luxonFormatToMomentFormat = (format) => {
+function 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;
- }
-};
+function isValidStaticFormat(format) {
+ return /^[\d\s/:-]+$/.test(DateTime.local().toFormat(format));
+}
+
+/**
+ * @param {Function} fn
+ * @returns {[any, null] | [null, Error]}
+ */
+function wrapError(fn) {
+ return (...args) => {
+ const result = [null, null];
+ try {
+ result[0] = fn(...args);
+ } catch (_err) {
+ result[1] = _err;
+ }
+ return result;
+ };
+}
/**
* Date picker
@@ -63,7 +79,7 @@ const isValidStaticFormat = (format) => {
*/
export class DatePicker extends Component {
setup() {
- this.root = useRef("root");
+ this.rootRef = useRef("root");
this.inputRef = useRef("input");
this.state = useState({ warning: false });
@@ -73,8 +89,7 @@ export class DatePicker extends Component {
this.datePickerShown = false;
this.initFormat();
- this.setDate(this.props);
- this.setFormat(this.props);
+ this.setDateAndFormat(this.props);
useAutofocus();
useExternalListener(window, "scroll", this.onWindowScroll);
@@ -88,43 +103,33 @@ export class DatePicker extends Component {
this.bootstrapDateTimePicker(this.props);
this.updateInput();
- window.$(this.root.el).on("show.datetimepicker", () => {
+ window.$(this.rootRef.el).on("show.datetimepicker", () => {
this.datePickerShown = true;
this.inputRef.el.select();
});
- window.$(this.root.el).on("hide.datetimepicker", () => {
+ window.$(this.rootRef.el).on("hide.datetimepicker", () => {
this.datePickerShown = false;
this.onDateChange({ useStatic: true });
});
- window.$(this.root.el).on("error.datetimepicker", () => false);
+ window.$(this.rootRef.el).on("error.datetimepicker", () => false);
}
onWillUpdateProps(nextProps) {
const pickerParams = {};
- let shouldUpdateInput = false;
for (const prop in nextProps) {
- const prev = this.props[prop];
- const next = nextProps[prop];
- if ((prev instanceof DateTime && !prev.equals(next)) || prev !== next) {
+ if (!areDateEquals(this.props[prop], nextProps[prop])) {
pickerParams[prop] = nextProps[prop];
- if (prop === "date") {
- this.setDate(nextProps);
- shouldUpdateInput = true;
- } else if (prop === "format") {
- this.setFormat(nextProps);
- shouldUpdateInput = true;
- }
}
}
- if (shouldUpdateInput) {
+ this.setDateAndFormat(nextProps);
+ if ("date" in pickerParams || "format" in pickerParams) {
this.updateInput();
}
this.bootstrapDateTimePicker(pickerParams);
}
onWillUnmount() {
- window.$(this.root.el).off(); // Removes all jQuery events
-
+ window.$(this.rootRef.el).off(); // Removes all jQuery events
this.bootstrapDateTimePicker("destroy");
}
@@ -136,7 +141,7 @@ export class DatePicker extends Component {
return {
format:
!useStatic || isValidStaticFormat(this.format) ? this.format : this.staticFormat,
- locale: this.date.locale,
+ locale: this.props.locale || (this.date && this.date.locale),
timezone: this.isLocal,
};
}
@@ -147,8 +152,8 @@ export class DatePicker extends Component {
initFormat() {
this.defaultFormat = localization.dateFormat;
this.staticFormat = "yyyy/MM/dd";
- this.formatValue = formatters.get("date");
- this.parseValue = parsers.get("date");
+ this.formatValue = wrapError(formatDate);
+ this.parseValue = wrapError(parseDate);
this.isLocal = false;
}
@@ -158,17 +163,10 @@ export class DatePicker extends Component {
* @param {Object} params
* @param {DateTime} params.date
* @param {string} [params.locale]
- */
- setDate({ date, locale }) {
- this.date = locale ? date.setLocale(locale) : date;
- }
-
- /**
- * Sets the current format.
- * @param {Object} params
* @param {string} [params.format]
*/
- setFormat({ format }) {
+ setDateAndFormat({ date, locale, format }) {
+ this.date = date && locale ? date.setLocale(locale) : date;
// Fallback to default localization format in `@web/core/l10n/dates.js`.
this.format = format || this.defaultFormat;
}
@@ -179,10 +177,9 @@ export class DatePicker extends Component {
* @param {boolean} [params.useStatic]
*/
updateInput({ useStatic } = {}) {
- try {
- this.inputRef.el.value = this.formatValue(this.date, this.getOptions(useStatic));
- } catch (_err) {
- // Do nothing
+ const [formattedDate] = this.formatValue(this.date, this.getOptions(useStatic));
+ if (formattedDate) {
+ this.inputRef.el.value = formattedDate;
}
}
@@ -197,20 +194,20 @@ export class DatePicker extends Component {
bootstrapDateTimePicker(commandOrParams) {
if (typeof commandOrParams === "object") {
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;
- }
+ const params = {
+ ...commandOrParams,
+ date: this.date || null,
+ format: luxonFormatToMomentFormat(format),
+ locale: commandOrParams.locale || (this.date && this.date.locale),
+ };
for (const prop in params) {
if (params[prop] instanceof DateTime) {
- const luxonDate = params[prop];
- const momentDate = luxonDateToMomentDate(luxonDate);
- params[prop] = momentDate;
+ params[prop] = luxonDateToMomentDate(params[prop]);
}
}
commandOrParams = params;
}
- return window.$(this.root.el).datetimepicker(commandOrParams);
+ return window.$(this.rootRef.el).datetimepicker(commandOrParams);
}
//---------------------------------------------------------------------
@@ -232,17 +229,26 @@ export class DatePicker extends Component {
* @param {boolean} [params.useStatic]
*/
onDateChange({ useStatic } = {}) {
- let date;
- try {
- date = this.parseValue(this.inputRef.el.value, this.getOptions(useStatic));
- } catch (_err) {
+ const { value } = this.inputRef.el;
+ const options = this.getOptions(useStatic);
+ const parsedDate = value && this.parseValue(value, options)[0];
+ this.state.warning = parsedDate && parsedDate > DateTime.local();
+ if (value && !parsedDate) {
// Reset to default (= given) date.
- }
- if (!date || date.equals(this.date)) {
this.updateInput();
- } else {
- this.state.warning = date > DateTime.local();
- this.props.onDateTimeChanged(date);
+ }
+ if (!areDateEquals(this.date, parsedDate)) {
+ this.props.onDateTimeChanged(parsedDate);
+ }
+ }
+
+ onInputKeydown(ev) {
+ const hotkey = getActiveHotkey(ev);
+ if (this.datePickerShown && hotkey === "escape") {
+ this.bootstrapDateTimePicker("hide");
+ this.inputRef.el.select();
+ ev.preventDefault();
+ ev.stopPropagation();
}
}
@@ -277,7 +283,7 @@ DatePicker.defaultProps = {
DatePicker.props = {
// Components props
onDateTimeChanged: Function,
- date: DateTime,
+ date: { type: [DateTime, { value: false }], optional: true },
warn_future: { type: Boolean, optional: true },
// Bootstrap datepicker options
buttons: {
@@ -313,6 +319,7 @@ DatePicker.props = {
readonly: { type: Boolean, optional: true },
useCurrent: { type: Boolean, optional: true },
widgetParent: { type: String, optional: true },
+ daysOfWeekDisabled: { type: Array, optional: true },
};
DatePicker.template = "web.DatePicker";
@@ -333,8 +340,8 @@ export class DateTimePicker extends DatePicker {
initFormat() {
this.defaultFormat = localization.dateTimeFormat;
this.staticFormat = "yyyy/MM/dd HH:mm:ss";
- this.formatValue = formatters.get("datetime");
- this.parseValue = parsers.get("datetime");
+ this.formatValue = wrapError(formatDateTime);
+ this.parseValue = wrapError(parseDateTime);
this.isLocal = true;
}
}
diff --git a/addons/web/static/src/core/datepicker/datepicker.xml b/addons/web/static/src/core/datepicker/datepicker.xml
index 3278d0c3705..66665ca7c52 100644
--- a/addons/web/static/src/core/datepicker/datepicker.xml
+++ b/addons/web/static/src/core/datepicker/datepicker.xml
@@ -13,10 +13,13 @@
t-ref="input"
t-on-change="() => this.onDateChange()"
t-on-click="onInputClick"
+ t-on-keydown="onInputKeydown"
+ />
+
-
- This date is on the future. Make sure it is what you expected.
-
diff --git a/addons/web/static/src/core/l10n/dates.js b/addons/web/static/src/core/l10n/dates.js
index a6ace94719a..8a737a2e4d8 100644
--- a/addons/web/static/src/core/l10n/dates.js
+++ b/addons/web/static/src/core/l10n/dates.js
@@ -51,6 +51,15 @@ const smartDateUnits = {
};
const smartDateRegex = new RegExp(`^([+-])(\\d+)([${Object.keys(smartDateUnits).join("")}]?)$`);
+/**
+ * @param {any} d1
+ * @param {any} d2
+ * @returns {boolean}
+ */
+export function areDateEquals(d1, d2) {
+ return d1 instanceof DateTime && d2 instanceof DateTime ? d1.equals(d2) : d1 === d2;
+}
+
/**
* Smart date inputs are shortcuts to write dates quicker.
* These shortcuts should respect the format ^[+-]\d+[dmwy]?$
@@ -86,14 +95,10 @@ function parseSmartDateInput(value) {
* Returns false otherwise.
*
* @param {DateTime | false} date
- * @returns {DateTime | false}
+ * @returns {boolean}
*/
-function constrain(dt) {
- let valid = dt !== false;
- valid = valid && dt.isValid;
- valid = valid && dt.year >= 1000;
- valid = valid && dt.year < 10000;
- return valid ? dt : false;
+function isValidDateTime(dt) {
+ return dt && dt.isValid && dt.year >= 1000 && dt.year < 10000;
}
/**
@@ -187,7 +192,7 @@ export function formatDateTime(value, options = {}) {
return "";
}
const format = options.format || localization.dateTimeFormat;
- const numberingSystem = options.numberingSystem || Settings.defaultNumberingSystem;
+ const numberingSystem = options.numberingSystem || Settings.defaultNumberingSystem || "latn";
const zone = options.timezone ? "system" : "utc";
value = value.setZone(zone, { keepLocaltime: options.timezone });
return value.toFormat(format, { numberingSystem });
@@ -208,6 +213,9 @@ export function formatDateTime(value, options = {}) {
* @returns {DateTime | false} Luxon DateTime object
*/
export function parseDate(value, options = {}) {
+ if (!value) {
+ return false;
+ }
return parseDateTime(value, { timezone: false, ...options }).startOf("day");
}
@@ -256,46 +264,75 @@ export function parseDateTime(value, options = {}) {
return false;
}
- const valueDigitsOnly = value.replace(nonDigitsRegex, "");
+ const fmt = options.format || localization.dateTimeFormat;
const parseOpts = {
setZone: true,
zone: options.timezone ? "system" : "utc",
locale: options.locale,
- numberingSystem: options.numberingSystem || Settings.defaultNumberingSystem,
+ numberingSystem: options.numberingSystem || Settings.defaultNumberingSystem || "latn",
};
- let result = constrain(parseSmartDateInput(value));
+ // Base case: try parsing with the given format and options
+ let result = DateTime.fromFormat(value, fmt, parseOpts);
- if (!result) {
- const fmt = options.format || localization.dateTimeFormat;
+ // Try parsing as a smart date
+ if (!isValidDateTime(result)) {
+ result = parseSmartDateInput(value);
+ }
+
+ // Try parsing with partial date parts
+ if (!isValidDateTime(result)) {
const fmtWoZero = stripAlphaDupes(fmt);
+ result = DateTime.fromFormat(value, fmtWoZero, parseOpts);
+ }
+ // Try parsing with custom shorthand date parts
+ if (!isValidDateTime(result)) {
// Luxon is not permissive regarding delimiting characters in the format.
// So if the value to parse has less characters than the format, we would
// try to parse without the delimiting characters.
- const woSeps = {
- val: valueDigitsOnly,
- fmt: fmt.replace(nonAlphaRegex, "").slice(0, valueDigitsOnly.length),
- };
+ const digitList = value.split(nonDigitsRegex).filter(Boolean);
+ const fmtList = fmt.split(nonAlphaRegex).filter(Boolean);
+ const valWoSeps = digitList.join("");
- result =
- constrain(DateTime.fromFormat(value, fmt, parseOpts)) ||
- constrain(DateTime.fromFormat(value, fmtWoZero, parseOpts)) ||
- constrain(DateTime.fromFormat(woSeps.val, woSeps.fmt, parseOpts));
+ // This is the weird part: we try to adapt the given format to comply with
+ // the amount of digits in the given value. To do this we split the format
+ // and the value on non-letter and non-digit characters respectively. This
+ // should create the same amount of grouping parameters, and the format
+ // groups are trimmed according to the length of their corresponding
+ // digit group. The 'carry' variable allows for the length of a digit
+ // group to overflow to the next format group. This is typically the case
+ // when the given value doesn't have non-digit separators and generates
+ // one big digit group instead.
+ let carry = 0;
+ const fmtWoSeps = fmtList
+ .map((part, i) => {
+ const digitLength = (digitList[i] || "").length;
+ const actualPart = part.slice(0, digitLength + carry);
+ carry += digitLength - actualPart.length;
+ return actualPart;
+ })
+ .join("");
+
+ result = DateTime.fromFormat(valWoSeps, fmtWoSeps, parseOpts);
}
- if (!result) {
- if (valueDigitsOnly.length > 4) {
- // Also try some fallback formats, but only if value counts more than
- // four digit characters as this could get misinterpreted as the time of
- // the actual date.
- result =
- constrain(DateTime.fromISO(value, parseOpts)) || // ISO8601
- constrain(DateTime.fromSQL(value, parseOpts)); // last try: SQL
+ // Try with defaul ISO or SQL formats
+ if (!isValidDateTime(result)) {
+ // Also try some fallback formats, but only if value counts more than
+ // four digit characters as this could get misinterpreted as the time of
+ // the actual date.
+ const valueDigits = value.replace(nonDigitsRegex, "");
+ if (valueDigits.length > 4) {
+ result = DateTime.fromISO(value, parseOpts); // ISO8601
+ if (!isValidDateTime(result)) {
+ result = DateTime.fromSQL(value, parseOpts); // last try: SQL
+ }
}
}
- if (!result) {
+ // No working parsing methods: throw an error
+ if (!isValidDateTime(result)) {
throw new Error(sprintf(_t("'%s' is not a correct date or datetime"), value));
}
@@ -307,45 +344,45 @@ export function parseDateTime(value, options = {}) {
* @param {string} value
* @returns {DateTime | false}
*/
-export const deserializeDate = (value) => {
+export function deserializeDate(value) {
return parseDate(value, {
format: SERVER_DATE_FORMAT,
numberingSystem: "latn",
});
-};
+}
/**
* Returns a datetime object parsed from the given serialized string.
* @param {string} value
* @returns {DateTime | false}
*/
-export const deserializeDateTime = (value) => {
+export function deserializeDateTime(value) {
return parseDateTime(value, {
format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}`,
numberingSystem: "latn",
});
-};
+}
/**
* Returns a serialized string representing the given date.
* @param {DateTime} value
* @returns {string}
*/
-export const serializeDate = (value) => {
+export function serializeDate(value) {
return formatDate(value, {
format: SERVER_DATE_FORMAT,
numberingSystem: "latn",
});
-};
+}
/**
* Returns a serialized string representing the given datetime.
* @param {DateTime} value
* @returns {string}
*/
-export const serializeDateTime = (value) => {
+export function serializeDateTime(value) {
return formatDateTime(value, {
format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}`,
numberingSystem: "latn",
});
-};
+}
diff --git a/addons/web/static/tests/core/datepicker_tests.js b/addons/web/static/tests/core/datepicker_tests.js
index ce3d76cb1b5..ce72f2a32a6 100644
--- a/addons/web/static/tests/core/datepicker_tests.js
+++ b/addons/web/static/tests/core/datepicker_tests.js
@@ -4,6 +4,7 @@ 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 { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import ActionModel from "web.ActionModel";
import CustomFilterItem from "web.CustomFilterItem";
import { createComponent } from "web.test_utils";
@@ -25,7 +26,7 @@ let target;
* @param {Object} props
* @returns {Promise}
*/
-const mountPicker = async (Picker, props) => {
+async function mountPicker(Picker, props) {
serviceRegistry
.add(
"localization",
@@ -34,7 +35,8 @@ const mountPicker = async (Picker, props) => {
dateTimeFormat: "dd/MM/yyyy HH:mm:ss",
})
)
- .add("ui", uiService);
+ .add("ui", uiService)
+ .add("hotkey", hotkeyService);
class Parent extends Component {
setup() {
@@ -54,9 +56,9 @@ const mountPicker = async (Picker, props) => {
const env = await makeTestEnv();
await mount(Parent, target, { env, props: { Picker } });
-};
+}
-const useFRLocale = () => {
+function useFRLocale() {
if (!window.moment.locales().includes("fr")) {
// Mocks the FR locale if not loaded
const originalLocale = window.moment.locale();
@@ -76,9 +78,9 @@ const useFRLocale = () => {
registerCleanup(() => window.moment.updateLocale("fr", null));
}
return "fr";
-};
+}
-const useNOLocale = () => {
+function useNOLocale() {
if (!window.moment.locales().includes("nb")) {
const originalLocale = window.moment.locale();
window.moment.defineLocale("nb", {
@@ -97,7 +99,7 @@ const useNOLocale = () => {
registerCleanup(() => window.moment.updateLocale("nb", null));
}
return "nb";
-};
+}
QUnit.module("Components", ({ beforeEach }) => {
beforeEach(() => {
@@ -527,4 +529,29 @@ QUnit.module("Components", ({ beforeEach }) => {
await editSelect(valueInput, "05/05/2005");
await applyFilter(target);
});
+
+ QUnit.test("start with no value", async function (assert) {
+ assert.expect(6);
+
+ await mountPicker(DateTimePicker, {
+ onDateTimeChanged(date) {
+ assert.step("datetime-changed");
+ assert.strictEqual(
+ date.toFormat("dd/MM/yyyy HH:mm:ss"),
+ "08/02/1997 15:45:05",
+ "Event should transmit the correct date"
+ );
+ },
+ });
+
+ const input = target.querySelector(".o_datepicker_input");
+ assert.strictEqual(input.value, "");
+
+ assert.verifySteps([]);
+ input.value = "08/02/1997 15:45:05";
+ await triggerEvent(target, ".o_datepicker_input", "change");
+
+ assert.verifySteps(["datetime-changed"]);
+ assert.strictEqual(input.value, "08/02/1997 15:45:05");
+ });
});
diff --git a/addons/web/static/tests/core/l10n/dates_tests.js b/addons/web/static/tests/core/l10n/dates_tests.js
index 0b40a6de5fb..866f60fff96 100644
--- a/addons/web/static/tests/core/l10n/dates_tests.js
+++ b/addons/web/static/tests/core/l10n/dates_tests.js
@@ -664,6 +664,33 @@ QUnit.module(
});
});
+ QUnit.test("parseDate with short notations", async (assert) => {
+ assert.strictEqual(
+ parseDate("20-10-20", { format: "yyyy-MM-dd" }).toISO(),
+ "2020-10-20T00:00:00.000Z"
+ );
+ assert.strictEqual(
+ parseDate("20/10/20", { format: "yyyy/MM/dd" }).toISO(),
+ "2020-10-20T00:00:00.000Z"
+ );
+ assert.strictEqual(
+ parseDate("10-20-20", { format: "MM-dd-yyyy" }).toISO(),
+ "2020-10-20T00:00:00.000Z"
+ );
+ assert.strictEqual(
+ parseDate("10-20-20", { format: "MM-yyyy-dd" }).toISO(),
+ "2020-10-20T00:00:00.000Z"
+ );
+ assert.strictEqual(
+ parseDate("1-20-2", { format: "MM-yyyy-dd" }).toISO(),
+ "2020-01-02T00:00:00.000Z"
+ );
+ assert.strictEqual(
+ parseDate("20/1/2", { format: "yyyy/MM/dd" }).toISO(),
+ "2020-01-02T00:00:00.000Z"
+ );
+ });
+
QUnit.test("parseDateTime", async (assert) => {
/**
* Type of testSet key: string
@@ -756,6 +783,13 @@ QUnit.module(
});
});
+ QUnit.test("parseDateTime with short notations", async (assert) => {
+ assert.strictEqual(
+ parseDateTime("20-10-20 8:5:3", { format: "yyyy-MM-dd hh:mm:ss" }).toISO(),
+ "2020-10-20T08:05:03.000Z"
+ );
+ });
+
QUnit.test("parseDateTime (with legacy options.isUTC = true)", async (assert) => {
/**
* Type of testSet key: string