From 0f4972443671cd13de1a48dc2cd97b6969cb92ee Mon Sep 17 00:00:00 2001 From: Bruno Boi Date: Tue, 30 Aug 2022 08:52:01 +0000 Subject: [PATCH] [IMP] web: simplify format/parse/deserialize date(time) utils The goal is to improve the dates utility functions (parse/format) by removing the timezone option, which is difficult to understand and has proven to be error prone. - Rationale When manipulating DateTime objects on the JS side, associated timezones must be always coherent. Until now, there were several code parts that had to check the DateTime timezone and handle things differently in a case or another. This sometimes lead to wrong code. E.g. there was a bug in the DatePicker component because the DateTime object it receives in props is in local timezone, but once it updates it through the parse utility, the new object is in UTC. - After this commit The DateTime objects you'll get through deserialization or parsing are always set in the user's local timezone. The formatted strings you'll get through formatDate and formatDateTime utils will always be expressed in the user's local timezone. The formatted strings you'll get through serialization utils will always be expressed in UTC. - serializeDate and serializeDateTime - expected input: a DateTime object (its timezone does not matter) - outputs: a string formatted for the server expressed in UTC - formatDate and formatDateTime - expected input: a DateTime object (its timezone does not matter) - outputs: a string formatted for the user, expressed in the user's TZ - deserializeDate and deserializeDateTime - expected input: a date(time) string provided by the server, in UTC - outputs: a DateTime object in user's TZ - parseDate and parseDateTime - expected input: a date(time) string provided by the user, in its TZ - outputs: a DateTime object in user's TZ - Other changes in this commit As the timezone option has been removed from parsing/formatting utils, all their usage had been adapted in the codebase. The large diff in dates_tests.js is because some tests were reorganized, others were removed/adapted/joined. A test has been removed from daterange_field_tests.js, because it has no sense: it displays a date field as a datetime, but a date field does not have any time information. Part-of: odoo/odoo#98980 Co-authored-by: Julien Mougenot --- .../static/src/models/tracking_value_item.js | 2 +- .../static/src/core/datepicker/datepicker.js | 3 +- .../fields/domain_selector_datetime_field.js | 23 +- .../fields/domain_selector_datetime_field.xml | 2 +- addons/web/static/src/core/l10n/dates.js | 75 ++--- .../src/search/search_bar/search_bar.js | 4 +- .../src/views/fields/date/date_field.js | 10 +- .../views/fields/daterange/daterange_field.js | 8 +- .../views/fields/properties/property_value.js | 45 +-- .../remaining_days/remaining_days_field.js | 17 +- .../static/src/views/list/list_renderer.js | 1 - .../web/static/src/views/relational_model.js | 4 +- .../tests/core/domain_selector_tests.js | 39 ++- .../web/static/tests/core/l10n/dates_tests.js | 257 +++++++++--------- .../web/static/tests/helpers/mock_server.js | 39 +-- .../views/fields/daterange_field_tests.js | 28 -- .../tests/views/fields/domain_field_tests.js | 77 ++++++ .../views/fields/properties_field_tests.js | 86 ++++++ 18 files changed, 416 insertions(+), 304 deletions(-) diff --git a/addons/mail/static/src/models/tracking_value_item.js b/addons/mail/static/src/models/tracking_value_item.js index 726b4e58564..1be3032aa85 100644 --- a/addons/mail/static/src/models/tracking_value_item.js +++ b/addons/mail/static/src/models/tracking_value_item.js @@ -46,7 +46,7 @@ registerModel({ return format.date(this.value); case 'datetime': { const value = this.value ? deserializeDateTime(this.value) : this.value; - return formatters.get("datetime")(value, { timezone: true }); + return formatters.get("datetime")(value); } case 'float': return format.float(this.value); diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js index c66fefd9363..81ba4fdbfa0 100644 --- a/addons/web/static/src/core/datepicker/datepicker.js +++ b/addons/web/static/src/core/datepicker/datepicker.js @@ -31,7 +31,7 @@ let datePickerId = 0; * @returns {moment} */ function luxonDateToMomentDate(date) { - return window.moment(String(date)); + return date.isValid ? window.moment(String(date)) : null; } /** @@ -136,7 +136,6 @@ export class DatePicker extends Component { format: !useStatic || isValidStaticFormat(this.format) ? this.format : this.staticFormat, locale: this.props.locale || (this.date && this.date.locale), - timezone: this.isLocal, }; } diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.js index cd3b62a497e..7a60c0e8a50 100644 --- a/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.js +++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.js @@ -1,12 +1,16 @@ /** @odoo-module **/ import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker"; -import { serializeDate, serializeDateTime } from "@web/core/l10n/dates"; +import { + deserializeDate, + deserializeDateTime, + serializeDate, + serializeDateTime, +} from "@web/core/l10n/dates"; import { registry } from "@web/core/registry"; const { Component } = owl; -const parsers = registry.category("parsers"); const dsf = registry.category("domain_selector/fields"); const dso = registry.category("domain_selector/operator"); @@ -15,16 +19,17 @@ export class DomainSelectorDateTimeField extends Component { const { DatePicker, DateTimePicker } = this.constructor.components; return this.props.field.type === "date" ? DatePicker : DateTimePicker; } - get parser() { - return parsers.get(this.props.field.type); + get deserializedValue() { + const deserialize = + this.props.field.type === "date" ? deserializeDate : deserializeDateTime; + return this.props.value ? deserialize(this.props.value) : luxon.DateTime.local(); } - get parsedValue() { - return this.props.value ? this.parser(this.props.value) : luxon.DateTime.local(); - } - onChange(value) { + if (!this.deserializedValue.isValid && !value) { + return; + } const serialize = this.props.field.type === "date" ? serializeDate : serializeDateTime; - this.props.update({ value: serialize(value) }); + this.props.update({ value: serialize(value || luxon.DateTime.local()) }); } } Object.assign(DomainSelectorDateTimeField, { diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.xml index a1f20a38d20..bbd3c3e9a9c 100644 --- a/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.xml +++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.xml @@ -3,7 +3,7 @@
- +
diff --git a/addons/web/static/src/core/l10n/dates.js b/addons/web/static/src/core/l10n/dates.js index 78bd7175c15..4f779a6fd49 100644 --- a/addons/web/static/src/core/l10n/dates.js +++ b/addons/web/static/src/core/l10n/dates.js @@ -171,18 +171,16 @@ export const luxonToMomentFormat = memoize(function luxonToMomentFormat(format) /** * Formats a DateTime object to a date string * - * `options.timezone` is defaulted to false on dates since we assume they - * shouldn't be affected by timezones like datetimes. - * * @see formatDateTime * @returns {string} */ export function formatDate(value, options = {}) { - return formatDateTime(value, { - timezone: false, // Timezone should never alter a 'date' value. - ...options, - format: options.format || localization.dateFormat, - }); + if (value === false) { + return ""; + } + const format = options.format || localization.dateFormat; + const numberingSystem = options.numberingSystem || Settings.defaultNumberingSystem || "latn"; + return value.toFormat(format, { numberingSystem }); } /** @@ -195,12 +193,6 @@ export function formatDate(value, options = {}) { * * Default=the session localization format. * - * @param {boolean} [options.timezone=true] - * - True = input will be set in local time before being formatted. - * - False = input will be set in UTC time before being formatted. - * - * Default=true. - * * @param {string} [options.numberingSystem] * Provided numbering system used to parse the input value. * @@ -215,9 +207,7 @@ export function formatDateTime(value, options = {}) { } const format = options.format || localization.dateTimeFormat; const numberingSystem = options.numberingSystem || Settings.defaultNumberingSystem || "latn"; - const zone = !("timezone" in options) || options.timezone ? "default" : "utc"; - value = value.setZone(zone, { keepLocaltime: options.timezone }); - return value.toFormat(format, { numberingSystem }); + return value.setZone("default").toFormat(format, { numberingSystem }); } // ----------------------------------------------------------------------------- @@ -227,18 +217,15 @@ export function formatDateTime(value, options = {}) { /** * Parses a string value to a Luxon DateTime object. * - * `options.timezone` is defaulted to false on dates since we assume they - * shouldn't be affected by timezones like datetimes. - * * @see parseDateTime (Note: since we're only interested by the date itself, the * returned value will always be set at the start of the day) - * @returns {DateTime | false} Luxon DateTime object + * @returns {DateTime | false} Luxon DateTime object in user's timezone */ export function parseDate(value, options = {}) { if (!value) { return false; } - return parseDateTime(value, { timezone: false, ...options }).startOf("day"); + return parseDateTime(value, options).startOf("day"); } /** @@ -247,10 +234,14 @@ export function parseDate(value, options = {}) { * @param {string} value value to parse. * - Value can take the form of a smart date: * e.g. "+3w" for three weeks from now. - * (`options.format` and `options.timezone` are ignored in this case) + * (`options.format` is ignored in this case) * * - If value cannot be parsed within the provided format, - * ISO8601 and SQL formats are then tried. + * ISO8601 and SQL formats are then tried. If these formats + * include a timezone information, the returned value will + * still be set to the user's timezone. + * e.g. "2020-01-01T12:00:00+06:00" with the user's timezone being UTC+1, + * the returned value will express the same timestamp but in UTC+1 (here time will be 7:00). * * @param {object} options * @param {string} [options.format] @@ -258,16 +249,6 @@ export function parseDate(value, options = {}) { * * Default=the session localization format * - * @param {boolean} [options.timezone=false] - * - True = input value is considered being in localtime. - * - False = input value is considered being in utc time, and the returned - * value will have the UTC zone. - * - * NB: ISO strings containing timezone information - * will have priority over this option. - * - * Default=false. - * * @param {string} [options.locale] * Provided locale used to parse the input value. * @@ -279,7 +260,7 @@ export function parseDate(value, options = {}) { * Default=the default numbering system assigned to luxon * @see localization_service.js * - * @returns {DateTime | false} Luxon DateTime object + * @returns {DateTime | false} Luxon DateTime object in user's timezone */ export function parseDateTime(value, options = {}) { if (!value) { @@ -289,7 +270,7 @@ export function parseDateTime(value, options = {}) { const fmt = options.format || localization.dateTimeFormat; const parseOpts = { setZone: true, - zone: options.timezone ? "default" : "utc", + zone: "default", locale: options.locale, numberingSystem: options.numberingSystem || Settings.defaultNumberingSystem || "latn", }; @@ -358,31 +339,31 @@ export function parseDateTime(value, options = {}) { throw new Error(sprintf(_t("'%s' is not a correct date or datetime"), value)); } - return options.timezone ? result : result.toUTC(); + return result.setZone("default"); } /** * Returns a date object parsed from the given serialized string. - * @param {string} value - * @returns {DateTime | false} + * @param {string} value serialized date string, e.g. "2018-01-01" + * @returns {DateTime} parsed date object in user's timezone */ export function deserializeDate(value) { - return DateTime.fromSQL(value, { zone: "utc", numberingSystem: "latn" }); + return DateTime.fromSQL(value, { zone: "default", numberingSystem: "latn" }); } /** * Returns a datetime object parsed from the given serialized string. - * @param {string} value - * @returns {DateTime | false} + * @param {string} value serialized datetime string, e.g. "2018-01-01 00:00:00", expressed in UTC + * @returns {DateTime} parsed datetime object in user's timezone */ export function deserializeDateTime(value) { - return DateTime.fromSQL(value, { zone: "utc", numberingSystem: "latn" }); + return DateTime.fromSQL(value, { zone: "utc", numberingSystem: "latn" }).setZone("default"); } /** * Returns a serialized string representing the given date. - * @param {DateTime} value - * @returns {string} + * @param {DateTime} value DateTime object, its timezone does not matter + * @returns {string} serialized date, ready to be sent to the server */ export function serializeDate(value) { return value.toFormat(SERVER_DATE_FORMAT, { numberingSystem: "latn" }); @@ -390,8 +371,8 @@ export function serializeDate(value) { /** * Returns a serialized string representing the given datetime. - * @param {DateTime} value - * @returns {string} + * @param {DateTime} value DateTime object, its timezone does not matter + * @returns {string} serialized datetime, ready to be sent to the server */ export function serializeDateTime(value) { return value.setZone("utc").toFormat(SERVER_DATETIME_FORMAT, { numberingSystem: "latn" }); diff --git a/addons/web/static/src/search/search_bar/search_bar.js b/addons/web/static/src/search/search_bar/search_bar.js index b5ac03aebd7..b07c70127c4 100644 --- a/addons/web/static/src/search/search_bar/search_bar.js +++ b/addons/web/static/src/search/search_bar/search_bar.js @@ -129,11 +129,11 @@ export class SearchBar extends Component { try { switch (type) { case "date": { - value = serializeDate(parser(trimmedQuery, { timezone: false })); + value = serializeDate(parser(trimmedQuery)); break; } case "datetime": { - value = serializeDateTime(parser(trimmedQuery, { timezone: true })); + value = serializeDateTime(parser(trimmedQuery)); break; } case "many2one": { diff --git a/addons/web/static/src/views/fields/date/date_field.js b/addons/web/static/src/views/fields/date/date_field.js index eb2ddeccd1d..530ccedbbac 100644 --- a/addons/web/static/src/views/fields/date/date_field.js +++ b/addons/web/static/src/views/fields/date/date_field.js @@ -1,7 +1,8 @@ /** @odoo-module **/ import { DatePicker } from "@web/core/datepicker/datepicker"; -import { areDateEquals, formatDate } from "@web/core/l10n/dates"; +import { areDateEquals, formatDate, formatDateTime } from "@web/core/l10n/dates"; +import { localization } from "@web/core/l10n/localization"; import { _lt } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; import { standardFieldProps } from "../standard_field_props"; @@ -17,10 +18,9 @@ export class DateField extends Component { } get formattedValue() { - return formatDate(this.props.value, { - // get local date if field type is datetime - timezone: this.isDateTime, - }); + return this.isDateTime + ? formatDateTime(this.props.value, { format: localization.dateFormat }) + : formatDate(this.props.value); } onDateTimeChanged(date) { diff --git a/addons/web/static/src/views/fields/daterange/daterange_field.js b/addons/web/static/src/views/fields/daterange/daterange_field.js index 89e70cc74f1..7d1cd1e95f3 100644 --- a/addons/web/static/src/views/fields/daterange/daterange_field.js +++ b/addons/web/static/src/views/fields/daterange/daterange_field.js @@ -87,7 +87,7 @@ export class DateRangeField extends Component { const formatter = formatters.get(format); let formattedValue; try { - formattedValue = formatter(value, { timezone: this.isDateTime }); + formattedValue = formatter(value); } catch { this.props.record.setInvalidField(this.props.name); } @@ -105,7 +105,7 @@ export class DateRangeField extends Component { const parse = parsers.get(this.props.formatType); let value; try { - value = parse(ev.target.value, { timezone: this.isDateTime }); + value = parse(ev.target.value); } catch { this.props.record.setInvalidField(this.props.name); return; @@ -129,9 +129,7 @@ export class DateRangeField extends Component { const end = this.isDateTime ? picker.endDate : picker.endDate.startOf("day"); const parser = parsers.get(this.props.formatType); const dates = [start, end].map((date) => { - return parser(date.format(this.momentFormat), { - timezone: this.isDateTime, - }); + return parser(date.format(this.momentFormat)); }); await this.updateRange(dates[0], dates[1]); const input = document.querySelector( diff --git a/addons/web/static/src/views/fields/properties/property_value.js b/addons/web/static/src/views/fields/properties/property_value.js index 21cc467f5f3..6fd11660f8c 100644 --- a/addons/web/static/src/views/fields/properties/property_value.js +++ b/addons/web/static/src/views/fields/properties/property_value.js @@ -12,16 +12,16 @@ import { m2oTupleFromData } from "@web/views/fields/many2one/many2one_field"; import { PropertyTags } from "./property_tags"; import { AutoComplete } from "@web/core/autocomplete/autocomplete"; import { formatFloat, formatInteger, formatMany2one } from "@web/views/fields/formatters"; -import { formatDate, formatDateTime } from "@web/core/l10n/dates"; +import { + deserializeDate, + deserializeDateTime, + formatDate, + formatDateTime, + serializeDate, + serializeDateTime, +} from "@web/core/l10n/dates"; const { Component } = owl; -const { DateTime } = luxon; - -// Formats to stringify the date / datetime in the JSON. -// It's important to have the year first, then the day, -// etc... in UTC, to be able to search on them. -const DEFAULT_SERVER_DATETIME_FORMAT = "yyyy-LL-dd HH:mm:ss"; -const DEFAULT_SERVER_DATE_FORMAT = "yyyy-LL-dd"; /** * Represent one property value. @@ -79,24 +79,9 @@ export class PropertyValue extends Component { // force to show at least 1 digit, even for integers return value; } else if (this.props.type === "datetime") { - if (typeof value === "string") { - // convert the datetime from the UTC format to the current timezone - const datetimeValue = DateTime.fromFormat( - value + " +00:00", - DEFAULT_SERVER_DATETIME_FORMAT + " Z" - ); - return datetimeValue.invalid ? false : datetimeValue; - } - return value instanceof DateTime ? value : false; + return typeof value === "string" ? deserializeDateTime(value) : value; } else if (this.props.type === "date") { - if (typeof value === "string") { - const datetimeValue = DateTime.fromFormat( - value + " +00:00", - DEFAULT_SERVER_DATE_FORMAT + " Z" - ); - return datetimeValue.invalid ? false : datetimeValue; - } - return value instanceof DateTime ? value : false; + return typeof value === "string" ? deserializeDate(value) : value; } else if (this.props.type === "boolean") { return !!value; } else if (this.props.type === "selection") { @@ -178,15 +163,9 @@ export class PropertyValue extends Component { */ async onValueChange(newValue) { if (this.props.type === "datetime") { - if (typeof newValue === "string") { - newValue = DateTime.fromISO(newValue); - } - newValue = newValue.toUTC().toFormat(DEFAULT_SERVER_DATETIME_FORMAT); + newValue = serializeDateTime(newValue); } else if (this.props.type === "date") { - if (typeof newValue === "string") { - newValue = DateTime.fromISO(newValue); - } - newValue = newValue.toFormat(DEFAULT_SERVER_DATE_FORMAT); + newValue = serializeDate(newValue); } else if (this.props.type === "integer") { newValue = parseInt(newValue) || 0; } else if (this.props.type === "float") { diff --git a/addons/web/static/src/views/fields/remaining_days/remaining_days_field.js b/addons/web/static/src/views/fields/remaining_days/remaining_days_field.js index 9a3ea2607f1..e07c3a54dc3 100644 --- a/addons/web/static/src/views/fields/remaining_days/remaining_days_field.js +++ b/addons/web/static/src/views/fields/remaining_days/remaining_days_field.js @@ -1,7 +1,8 @@ /** @odoo-module **/ import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker"; -import { formatDate } from "@web/core/l10n/dates"; +import { formatDate, formatDateTime } from "@web/core/l10n/dates"; +import { localization } from "@web/core/l10n/localization"; import { registry } from "@web/core/registry"; import { _lt } from "@web/core/l10n/translation"; import { standardFieldProps } from "../standard_field_props"; @@ -21,18 +22,14 @@ export class RemainingDaysField extends Component { if (!this.props.value) { return null; } - const today = this.correctDate(luxon.DateTime.utc()).startOf("day"); - return Math.floor( - this.correctDate(this.props.value).startOf("day").diff(today, "days").days - ); + const today = luxon.DateTime.local().startOf("day"); + return Math.floor(this.props.value.startOf("day").diff(today, "days").days); } get formattedValue() { - return this.props.value ? formatDate(this.props.value, { timezone: this.hasTime }) : ""; - } - - correctDate(date) { - return this.hasTime ? date.toLocal() : date.toUTC(); + return this.hasTime + ? formatDateTime(this.props.value, { format: localization.dateFormat }) + : formatDate(this.props.value); } onDateTimeChanged(datetime) { diff --git a/addons/web/static/src/views/list/list_renderer.js b/addons/web/static/src/views/list/list_renderer.js index cca9fb72e8f..64aca9ba49a 100644 --- a/addons/web/static/src/views/list/list_renderer.js +++ b/addons/web/static/src/views/list/list_renderer.js @@ -690,7 +690,6 @@ export class ListRenderer extends Component { isPassword: "password" in column.rawAttrs, digits: column.rawAttrs.digits ? JSON.parse(column.rawAttrs.digits) : field.digits, field: record.fields[fieldName], - timezone: true, }; return formatter(record.data[fieldName], formatOptions); } diff --git a/addons/web/static/src/views/relational_model.js b/addons/web/static/src/views/relational_model.js index 5f3e00c7a74..033c5dc801f 100644 --- a/addons/web/static/src/views/relational_model.js +++ b/addons/web/static/src/views/relational_model.js @@ -435,9 +435,9 @@ export class Record extends DataPoint { evalContext[fieldName] = list.getContext(); // ---> implied to initialize (resIds, commands) currentIds before loading static list } else if (value && this.fields[fieldName].type === "date") { - evalContext[fieldName] = value.toFormat("yyyy-LL-dd"); + evalContext[fieldName] = serializeDate(value); } else if (value && this.fields[fieldName].type === "datetime") { - evalContext[fieldName] = value.toFormat("yyyy-LL-dd HH:mm:ss"); + evalContext[fieldName] = serializeDateTime(value); } else if (value && this.fields[fieldName].type === "many2one") { evalContext[fieldName] = value[0]; } else if (value && this.fields[fieldName].type === "reference") { diff --git a/addons/web/static/tests/core/domain_selector_tests.js b/addons/web/static/tests/core/domain_selector_tests.js index fc36adebeea..46cfcef9742 100644 --- a/addons/web/static/tests/core/domain_selector_tests.js +++ b/addons/web/static/tests/core/domain_selector_tests.js @@ -185,7 +185,7 @@ QUnit.module("Components", (hooks) => { }); QUnit.test("building a domain with a datetime", async (assert) => { - assert.expect(2); + assert.expect(4); // Create the domain selector and its mock environment await mountComponent(DomainSelector, { @@ -205,8 +205,11 @@ QUnit.module("Components", (hooks) => { // Check that there is a datepicker to choose the date assert.containsOnce(target, ".o_datepicker", "there should be a datepicker"); - await click(target, ".o_datepicker_input"); + // The input field should display the date and time in the user's timezone + assert.equal(target.querySelector(".o_datepicker_input").value, "03/27/2017 16:42:00"); + // Change the date in the datepicker + await click(target, ".o_datepicker_input"); await click( document.body.querySelector( `.bootstrap-datetimepicker-widget :not(.today)[data-action="selectDay"]` @@ -215,6 +218,38 @@ QUnit.module("Components", (hooks) => { await click( document.body.querySelector(`.bootstrap-datetimepicker-widget a[data-action="close"]`) ); + + // The input field should display the date and time in the user's timezone + assert.equal(target.querySelector(".o_datepicker_input").value, "02/26/2017 16:42:00"); + }); + + QUnit.test("building a domain with a datetime: context_today()", async (assert) => { + // Create the domain selector and its mock environment + await mountComponent(DomainSelector, { + props: { + resModel: "partner", + value: `[("datetime", "=", context_today())]`, + readonly: false, + update: () => { + assert.step("SHOULD NEVER BE CALLED"); + }, + }, + }); + + // Check that there is a datepicker to choose the date + assert.containsOnce(target, ".o_datepicker", "there should be a datepicker"); + // The input field should display that the date is invalid + assert.equal(target.querySelector(".o_datepicker_input").value, "Invalid DateTime"); + + // Open and close the datepicker + await click(target, ".o_datepicker_input"); + await click( + document.body.querySelector(`.bootstrap-datetimepicker-widget [data-action=close]`) + ); + + // The input field should display an empty value. NB: this could be improved, but OK for now + assert.equal(target.querySelector(".o_datepicker_input").value, ""); + assert.verifySteps([]); }); QUnit.test("building a domain with a m2o without following the relation", async (assert) => { diff --git a/addons/web/static/tests/core/l10n/dates_tests.js b/addons/web/static/tests/core/l10n/dates_tests.js index 48a239d8f4b..e181b881a53 100644 --- a/addons/web/static/tests/core/l10n/dates_tests.js +++ b/addons/web/static/tests/core/l10n/dates_tests.js @@ -19,7 +19,7 @@ import session from "web.session"; import test_utils from "web.test_utils"; import { registerCleanup } from "../../helpers/cleanup"; import { defaultLocalization } from "../../helpers/mock_services"; -import { patchDate, patchWithCleanup } from "../../helpers/utils"; +import { patchDate, patchTimeZone, patchWithCleanup } from "../../helpers/utils"; const { DateTime, Settings } = luxon; @@ -81,89 +81,75 @@ QUnit.module( () => { QUnit.module("dates"); - QUnit.test("formatDate", async (assert) => { - patch(localization, "dateformat", { dateFormat: "MM/dd/yyyy" }); + QUnit.test("formatDate/formatDateTime specs", async (assert) => { + patchWithCleanup(localization, { + dateFormat: "MM/dd/yyyy", + dateTimeFormat: "MM/dd/yyyy HH:mm:ss", + }); + patchTimeZone(60); + patchDate(2009, 4, 4, 12, 34, 56); - let formatted = formatDate(DateTime.utc(2009, 5, 4, 12, 34, 23)); - let expected = "05/04/2009"; - assert.strictEqual(formatted, expected); + const utc = DateTime.utc(); // 2009-05-04T11:34:56.000Z + const local = DateTime.local(); // 2009-05-04T12:34:56.000+01:00 + const minus13FromLocalTZ = local.setZone("UTC-12"); // 2009-05-03T23:34:56.000-12:00 - formatted = formatDate(DateTime.utc(2009, 5, 4, 12, 34, 23), { timezone: false }); - assert.strictEqual(formatted, expected); + // For dates, regardless of the input timezone, outputs only the date + assert.strictEqual(formatDate(utc), "05/04/2009"); + assert.strictEqual(formatDate(local), "05/04/2009"); + assert.strictEqual(formatDate(minus13FromLocalTZ), "05/03/2009"); - formatted = formatDate(DateTime.utc(2009, 5, 4, 12, 34, 23), { timezone: true }); - expected = "05/04/2009"; - assert.strictEqual(formatted, expected); - - unpatch(localization, "dateformat"); + // For datetimes, input timezone is taken into account, outputs in local timezone + assert.strictEqual(formatDateTime(utc), "05/04/2009 12:34:56"); + assert.strictEqual(formatDateTime(local), "05/04/2009 12:34:56"); + assert.strictEqual(formatDateTime(minus13FromLocalTZ), "05/04/2009 12:34:56"); }); - QUnit.test("formatDate (with different timezone offset)", async (assert) => { - patch(localization, "dateformat", { dateFormat: "MM/dd/yyyy" }); + QUnit.test("formatDate/formatDateTime specs, at midnight", async (assert) => { + patchWithCleanup(localization, { + dateFormat: "MM/dd/yyyy", + dateTimeFormat: "MM/dd/yyyy HH:mm:ss", + }); + patchTimeZone(60); + patchDate(2009, 4, 4, 0, 0, 0); - let str = formatDate(DateTime.utc(2017, 1, 1, 10, 0, 0, 0)); - assert.strictEqual(str, "01/01/2017"); - str = formatDate(DateTime.utc(2017, 6, 1, 10, 0, 0, 0)); - assert.strictEqual(str, "06/01/2017"); + const utc = DateTime.utc(); // 2009-05-03T23:00:00.000Z + const local = DateTime.local(); // 2009-05-04T00:00:00.000+01:00 + const minus13FromLocalTZ = local.setZone("UTC-12"); // 2009-05-03T11:00:00.000-12:00 - str = formatDate(DateTime.utc(2017, 1, 1, 10, 0, 0, 0), { timezone: false }); - assert.strictEqual(str, "01/01/2017"); - str = formatDate(DateTime.utc(2017, 6, 1, 10, 0, 0, 0), { timezone: false }); - assert.strictEqual(str, "06/01/2017"); + // For dates, regardless of the input timezone, outputs only the date + assert.strictEqual(formatDate(utc), "05/03/2009"); + assert.strictEqual(formatDate(local), "05/04/2009"); + assert.strictEqual(formatDate(minus13FromLocalTZ), "05/03/2009"); - str = formatDate(DateTime.utc(2017, 1, 1, 10, 0, 0, 0), { timezone: true }); - assert.strictEqual(str, "01/01/2017"); - str = formatDate(DateTime.utc(2017, 6, 1, 10, 0, 0, 0), { timezone: true }); - assert.strictEqual(str, "06/01/2017"); - - unpatch(localization, "dateformat"); + // For datetimes, input timezone is taken into account, outputs in local timezone + assert.strictEqual(formatDateTime(utc), "05/04/2009 00:00:00"); + assert.strictEqual(formatDateTime(local), "05/04/2009 00:00:00"); + assert.strictEqual(formatDateTime(minus13FromLocalTZ), "05/04/2009 00:00:00"); }); - QUnit.test("formatDate (with DateTime.fromISO)", async (assert) => { - patchWithCleanup(localization, { dateFormat: "MM/dd/yyyy" }); + QUnit.test("parseDate(Time) outputs DateTime objects in local TZ", async (assert) => { + patchWithCleanup(localization, defaultLocalization); - // NB: the local timezone in test env is UTC+1 - let date = DateTime.fromISO("2022-07-21T22:00:00Z"); - assert.strictEqual(formatDate(date), "07/21/2022"); - assert.strictEqual(formatDate(date, { timezone: true }), "07/21/2022"); + patchTimeZone(60); + assert.equal(parseDate("01/13/2019").toISO(), "2019-01-13T00:00:00.000+01:00"); + assert.equal( + parseDateTime("01/13/2019 10:05:45").toISO(), + "2019-01-13T10:05:45.000+01:00" + ); - date = DateTime.fromISO("2022-07-21T23:00:00Z"); - assert.strictEqual(formatDate(date), "07/21/2022"); - assert.strictEqual(formatDate(date, { timezone: true }), "07/22/2022"); + patchTimeZone(330); + assert.equal(parseDate("01/13/2019").toISO(), "2019-01-13T00:00:00.000+05:30"); + assert.equal( + parseDateTime("01/13/2019 10:05:45").toISO(), + "2019-01-13T10:05:45.000+05:30" + ); - date = DateTime.fromISO("2022-07-22T00:00:00Z"); - assert.strictEqual(formatDate(date), "07/22/2022"); - assert.strictEqual(formatDate(date, { timezone: true }), "07/22/2022"); - - // Here are the 3 same instants as above, but expressed from a different ISO string - date = DateTime.fromISO("2022-07-22T00:00:00+02:00"); - assert.strictEqual(formatDate(date), "07/21/2022"); - assert.strictEqual(formatDate(date, { timezone: true }), "07/21/2022"); - - date = DateTime.fromISO("2022-07-22T01:00:00+02:00"); - assert.strictEqual(formatDate(date), "07/21/2022"); - assert.strictEqual(formatDate(date, { timezone: true }), "07/22/2022"); - - date = DateTime.fromISO("2022-07-22T02:00:00+02:00"); - assert.strictEqual(formatDate(date), "07/22/2022"); - assert.strictEqual(formatDate(date, { timezone: true }), "07/22/2022"); - }); - - QUnit.test("formatDateTime", async (assert) => { - patchWithCleanup(localization, { dateTimeFormat: "MM/dd/yyyy HH:mm:ss" }); - const date = DateTime.utc(2009, 5, 4, 12, 34, 23); - assert.strictEqual(formatDateTime(date), "05/04/2009 13:34:23"); - assert.strictEqual(formatDateTime(date, { timezone: false }), "05/04/2009 12:34:23"); - }); - - QUnit.test("formatDateTime (with different timezone offset)", async (assert) => { - patchWithCleanup(localization, { dateTimeFormat: "MM/dd/yyyy HH:mm:ss" }); - let date = DateTime.fromISO("2017-01-01T11:00:00+01:00"); - assert.strictEqual(formatDateTime(date), "01/01/2017 11:00:00"); - assert.strictEqual(formatDateTime(date, { timezone: false }), "01/01/2017 10:00:00"); - date = DateTime.fromISO("2017-06-01T11:00:00+02:00"); - assert.strictEqual(formatDateTime(date), "06/01/2017 10:00:00"); - assert.strictEqual(formatDateTime(date, { timezone: false }), "06/01/2017 09:00:00"); + patchTimeZone(-660); + assert.equal(parseDate("01/13/2019").toISO(), "2019-01-13T00:00:00.000-11:00"); + assert.equal( + parseDateTime("01/13/2019 10:05:45").toISO(), + "2019-01-13T10:05:45.000-11:00" + ); }); QUnit.test("parseDateTime", async (assert) => { @@ -194,7 +180,7 @@ QUnit.module( parseDateTime("invalid value"); }, /is not a correct/); - const expected = "2019-01-13T10:05:45.000Z"; + const expected = "2019-01-13T10:05:45.000+01:00"; let dateStr = "01/13/2019 10:05:45"; assert.equal(parseDateTime(dateStr).toISO(), expected, "Date with leading 0"); dateStr = "1/13/2019 10:5:45"; @@ -221,7 +207,7 @@ QUnit.module( Settings.defaultLocale = "no"; // Norwegian const dateStr = "16. des 2019 10:05:45"; - const expected = "2019-12-16T10:05:45.000Z"; + const expected = "2019-12-16T10:05:45.000+01:00"; assert.equal( parseDateTime(dateStr).toISO(), expected, @@ -236,18 +222,10 @@ QUnit.module( patchWithCleanup(localization, defaultLocalization); let str = "07/21/2022"; - assert.strictEqual(parseDate(str).toISO(), "2022-07-21T00:00:00.000Z"); - assert.strictEqual( - parseDate(str, { timezone: true }).toISO(), - "2022-07-21T00:00:00.000+01:00" - ); + assert.strictEqual(parseDate(str).toISO(), "2022-07-21T00:00:00.000+01:00"); str = "07/22/2022"; - assert.strictEqual(parseDate(str).toISO(), "2022-07-22T00:00:00.000Z"); - assert.strictEqual( - parseDate(str, { timezone: true }).toISO(), - "2022-07-22T00:00:00.000+01:00" - ); + assert.strictEqual(parseDate(str).toISO(), "2022-07-22T00:00:00.000+01:00"); }); QUnit.test("parseDate without separator", async (assert) => { @@ -376,15 +354,29 @@ QUnit.module( ); }); + QUnit.test("parseDateTime ISO8601 Format", async (assert) => { + patchWithCleanup(localization, defaultLocalization); + patchTimeZone(60); + assert.equal( + parseDateTime("2017-05-15T12:00:00.000+06:00").toISO(), + "2017-05-15T07:00:00.000+01:00" + ); + // without the 'T' separator is not really ISO8601 compliant, but we still support it + assert.equal( + parseDateTime("2017-05-15 12:00:00.000+06:00").toISO(), + "2017-05-15T07:00:00.000+01:00" + ); + }); + QUnit.test("parseDateTime SQL Format", async (assert) => { patch(localization, "default loc", defaultLocalization); let dateStr = "2017-05-15 09:12:34"; - let expected = "2017-05-15T09:12:34.000Z"; + let expected = "2017-05-15T09:12:34.000+01:00"; assert.equal(parseDateTime(dateStr).toISO(), expected, "Date with SQL format"); dateStr = "2017-05-08 09:12:34"; - expected = "2017-05-08T09:12:34.000Z"; + expected = "2017-05-08T09:12:34.000+01:00"; assert.equal( parseDateTime(dateStr).toISO(), expected, @@ -457,9 +449,9 @@ QUnit.module( }); QUnit.test("deserializeDate", async (assert) => { - const date = DateTime.utc(2022, 2, 21); + const date = DateTime.local(2022, 2, 21); assert.strictEqual( - DateTime.fromFormat("2022-02-21", "yyyy-MM-dd", { zone: "utc" }).toMillis(), + DateTime.fromFormat("2022-02-21", "yyyy-MM-dd").toMillis(), date.toMillis() ); assert.strictEqual(deserializeDate("2022-02-21").toMillis(), date.toMillis()); @@ -467,9 +459,9 @@ QUnit.module( QUnit.test("deserializeDate with different numbering system", async (assert) => { patchWithCleanup(Settings, { defaultNumberingSystem: "arab" }); - const date = DateTime.utc(2022, 2, 21); + const date = DateTime.local(2022, 2, 21); assert.strictEqual( - DateTime.fromFormat("٢٠٢٢-٠٢-٢١", "yyyy-MM-dd", { zone: "utc" }).toMillis(), + DateTime.fromFormat("٢٠٢٢-٠٢-٢١", "yyyy-MM-dd").toMillis(), date.toMillis() ); assert.strictEqual(deserializeDate("2022-02-21").toMillis(), date.toMillis()); @@ -504,6 +496,40 @@ 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.000+01:00" + ); + assert.strictEqual( + parseDate("20/10/20", { format: "yyyy/MM/dd" }).toISO(), + "2020-10-20T00:00:00.000+01:00" + ); + assert.strictEqual( + parseDate("10-20-20", { format: "MM-dd-yyyy" }).toISO(), + "2020-10-20T00:00:00.000+01:00" + ); + assert.strictEqual( + parseDate("10-20-20", { format: "MM-yyyy-dd" }).toISO(), + "2020-10-20T00:00:00.000+01:00" + ); + assert.strictEqual( + parseDate("1-20-2", { format: "MM-yyyy-dd" }).toISO(), + "2020-01-02T00:00:00.000+01:00" + ); + assert.strictEqual( + parseDate("20/1/2", { format: "yyyy/MM/dd" }).toISO(), + "2020-01-02T00:00:00.000+01:00" + ); + }); + + 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.000+01:00" + ); + }); + QUnit.module("dates utils compatibility with legacy", { beforeEach() { patchWithCleanup(localization, { @@ -544,6 +570,9 @@ QUnit.module( }); QUnit.test("parseDate", async (assert) => { + // Patch the timezone to no offset, as the legacy parsing always outputs + // a local date/datetime but as UTC (keeping the local time, which is wrong...) + patchTimeZone(0); /** * Type of testSet key: string * Type of testSet value: [newExpected: string, legacyExpected: string] @@ -674,6 +703,9 @@ QUnit.module( }); QUnit.test("parseDate (with legacy options.isUTC = true)", async (assert) => { + // Patch the timezone to no offset, as the legacy parsing always outputs + // a local date/datetime but as UTC (keeping the local time, which is wrong...) + patchTimeZone(0); /** * Type of testSet key: string * Type of testSet value: [newExpected: string, legacyExpected: string] @@ -721,40 +753,16 @@ QUnit.module( ]); runTestSet(assert, testSet, { - newFn: (input) => parseDate(input, { format: "YYYY-MM-DD" }).toISO(), + newFn: (input) => parseDate(input).setZone("utc", { keepLocalTime: true }).toISO(), legacyFn: (input) => legacy.field_utils.parse.date(input, null, { isUTC: true }).toISOString(), }); }); - 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) => { + // Patch the timezone to no offset, as the legacy parsing always outputs + // a local date/datetime but as UTC (keeping the local time, which is wrong...) + patchTimeZone(0); /** * Type of testSet key: string * Type of testSet value: [newExpected: string, legacyExpected: string] @@ -846,14 +854,10 @@ 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) => { + // Patch the timezone to no offset, as the legacy parsing always outputs + // a local date/datetime but as UTC (keeping the local time, which is wrong...) + patchTimeZone(0); /** * Type of testSet key: string * Type of testSet value: [newExpected: string, legacyExpected: string] @@ -902,7 +906,8 @@ QUnit.module( } runTestSet(assert, testSet, { - newFn: (input) => parseDateTime(input, { format: "YYYY-MM-DD HH:mm:ss" }).toISO(), + newFn: (input) => + parseDateTime(input).setZone("utc", { keepLocalTime: true }).toISO(), legacyFn: (input) => legacy.field_utils.parse.datetime(input, null, { isUTC: true }).toISOString(), }); @@ -1019,7 +1024,7 @@ QUnit.module( ]); runTestSet(assert, testSet, { - newFn: (input) => parseDateTime(input, { timezone: true }).toUTC().toISO(), + newFn: (input) => parseDateTime(input).toUTC().toISO(), legacyFn: (input) => legacy.field_utils.parse .datetime(input, null, { timezone: true }) diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index 4a9cb67319a..568b6a26794 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -879,7 +879,7 @@ export class MockServer { const [fieldName, aggregateFunction = "month"] = groupByField.split(":"); const { type } = fields[fieldName]; if (type === "date") { - const date = deserializeDate(val).setZone("default"); + const date = deserializeDate(val); if (aggregateFunction === "day") { return date.toFormat("yyyy-MM-dd"); } else if (aggregateFunction === "week") { @@ -892,7 +892,7 @@ export class MockServer { return date.toFormat("MMMM yyyy"); } } else if (type === "datetime") { - const date = deserializeDateTime(val).setZone("default"); + const date = deserializeDateTime(val); if (aggregateFunction === "hour") { return date.toFormat("HH:00 dd MMM"); } else if (aggregateFunction === "day") { @@ -987,57 +987,36 @@ export class MockServer { switch (dateRange) { case "hour": { try { - startDate = parseDateTime(value, { - format: "HH dd MMM", - timezone: type !== "date", - }); + startDate = parseDateTime(value, { format: "HH dd MMM" }); } catch { - startDate = parseDateTime(value, { - format: "HH:00 dd MMM", - timezone: type !== "date", - }); + startDate = parseDateTime(value, { format: "HH:00 dd MMM" }); } endDate = startDate.plus({ hours: 1 }); break; } case "day": { - startDate = parseDateTime(value, { - format: "yyyy-MM-dd", - timezone: type !== "date", - }); + startDate = parseDateTime(value, { format: "yyyy-MM-dd" }); endDate = startDate.plus({ days: 1 }); break; } case "week": { - startDate = parseDateTime(value, { - format: "WW kkkk", - timezone: type !== "date", - }); + startDate = parseDateTime(value, { format: "WW kkkk" }); endDate = startDate.plus({ weeks: 1 }); break; } case "quarter": { - startDate = parseDateTime(value, { - format: "q yyyy", - timezone: type !== "date", - }); + startDate = parseDateTime(value, { format: "q yyyy" }); endDate = startDate.plus({ quarters: 1 }); break; } case "year": { - startDate = parseDateTime(value, { - format: "y", - timezone: type !== "date", - }); + startDate = parseDateTime(value, { format: "y" }); endDate = startDate.plus({ years: 1 }); break; } case "month": default: { - startDate = parseDateTime(value, { - format: "MMMM yyyy", - timezone: type !== "date", - }); + startDate = parseDateTime(value, { format: "MMMM yyyy" }); endDate = startDate.plus({ months: 1 }); break; } diff --git a/addons/web/static/tests/views/fields/daterange_field_tests.js b/addons/web/static/tests/views/fields/daterange_field_tests.js index b3e83d7fa2e..58c6bbc5028 100644 --- a/addons/web/static/tests/views/fields/daterange_field_tests.js +++ b/addons/web/static/tests/views/fields/daterange_field_tests.js @@ -601,32 +601,4 @@ QUnit.module("Fields", (hooks) => { "the end date should only show date when option formatType is Date" ); }); - - QUnit.test("Date field with option format type is 'datetime'", async function (assert) { - serverData.models.partner.fields.date_end = { string: "Date End", type: "date" }; - serverData.models.partner.records[0].date_end = "2017-03-13"; - - await makeView({ - type: "form", - resModel: "partner", - resId: 1, - serverData, - arch: ` -
- - - `, - }); - - assert.strictEqual( - target.querySelector(".o_field_daterange[name='date']").textContent, - "02/03/2017 05:30:00", - "the start date should show date with time when option format_type is datatime" - ); - assert.strictEqual( - target.querySelector(".o_field_daterange[name='date_end']").textContent, - "03/13/2017 05:30:00", - "the end date should show date with time when option format_type is datatime" - ); - }); }); diff --git a/addons/web/static/tests/views/fields/domain_field_tests.js b/addons/web/static/tests/views/fields/domain_field_tests.js index 1c7715eaff1..a4460e31347 100644 --- a/addons/web/static/tests/views/fields/domain_field_tests.js +++ b/addons/web/static/tests/views/fields/domain_field_tests.js @@ -723,6 +723,83 @@ QUnit.module("Fields", (hooks) => { await click(target, ".o_form_button_save"); }); + QUnit.test("domain field: edit through selector (dynamic content)", async function (assert) { + patchWithCleanup(odoo, { debug: true }); + + let rawDomain = `[("date", ">=", context_today())]`; + serverData.models.partner.records[0].foo = rawDomain; + serverData.models.partner.fields.bar.type = "char"; + serverData.models.partner.records[0].bar = "partner"; + + serverData.views = { + "partner,false,form": ` +
+ + + `, + "partner,false,search": ``, + }; + + serverData.actions = { + 1: { + id: 1, + name: "test", + res_id: 1, + res_model: "partner", + type: "ir.actions.act_window", + views: [[false, "form"]], + }, + }; + + const webClient = await createWebClient({ + serverData, + mockRPC(route, { method }) { + assert.step(method || route); + }, + }); + assert.verifySteps(["/web/webclient/load_menus"]); + + await doAction(webClient, 1); + assert.verifySteps(["/web/action/load", "get_views", "read", "search_count", "fields_get"]); + + await click(target, ".o_form_button_edit"); + assert.verifySteps(["search_count"]); + assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain); + assert.containsOnce(target, ".o_datepicker", "there should be a datepicker"); + + // Open and close the datepicker + await click(target, ".o_datepicker_input"); + assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget"); + await triggerEvent(window, null, "scroll"); + assert.containsNone(document.body, ".bootstrap-datetimepicker-widget"); + assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain); + assert.verifySteps([]); + + // Save + await click(target, ".o_form_button_save"); + assert.verifySteps(["search_count"]); + + // Edit again + await click(target, ".o_form_button_edit"); + assert.verifySteps(["search_count"]); + assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain); + + // Manually input a date + rawDomain = `[("date", ">=", "2020-09-09")]`; + await editInput(target, ".o_datepicker_input", "09/09/2020"); + assert.verifySteps(["search_count"]); + assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain); + + // Save + await click(target, ".o_form_button_save"); + assert.verifySteps(["write", "read", "search_count"]); + + // Edit again + await click(target, ".o_form_button_edit"); + assert.verifySteps(["search_count"]); + assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain); + }); + QUnit.test("domain field without model", async function (assert) { serverData.models.partner.fields.model_name = { string: "Model name", type: "char" }; diff --git a/addons/web/static/tests/views/fields/properties_field_tests.js b/addons/web/static/tests/views/fields/properties_field_tests.js index bec09bd8bb4..c84884210c2 100644 --- a/addons/web/static/tests/views/fields/properties_field_tests.js +++ b/addons/web/static/tests/views/fields/properties_field_tests.js @@ -862,4 +862,90 @@ QUnit.module("Fields", (hooks) => { "Should have removed Bob from the list" ); }); + + QUnit.test("properties: date(time) property manipulations", async function (assert) { + serverData.models.partner.records.push({ + id: 3, + display_name: "third partner", + properties: [ + { + name: "property_1", + string: "My Date", + type: "date", + value: "2019-01-01", + }, + { + name: "property_2", + string: "My DateTime", + type: "datetime", + value: "2019-01-01 10:00:00", + }, + ], + company_id: 37, + }); + await makeView({ + type: "form", + resModel: "partner", + resId: 3, + serverData, + arch: `
`, + mockRPC(route, { method, args }) { + assert.step(method); + if (method === "check_access_rights") { + return true; + } + if (method === "write") { + assert.deepEqual(args[1].properties, [ + { + name: "property_1", + string: "My Date", + type: "date", + value: "2018-12-31", + }, + { + name: "property_2", + string: "My DateTime", + type: "datetime", + value: "2018-12-31 11:01:01", + }, + ]); + } + }, + }); + assert.verifySteps(["get_views", "read", "check_access_rights"]); + + // check initial properties + assert.equal( + target.querySelector("[property-name=property_1] .o_property_field_value").innerText, + "01/01/2019" + ); + assert.equal( + target.querySelector("[property-name=property_2] .o_property_field_value").innerText, + "01/01/2019 11:00:00" + ); + + // edit date property + await click(target, ".o_form_button_edit"); + await click(target, ".o_property_field[property-name=property_1] input"); + await click(document.body, ".datepicker [data-day='12/31/2018']"); + assert.equal(target.querySelector("[property-name=property_1] input").value, "12/31/2018"); + + // edit date time property + await click(target, ".o_property_field[property-name=property_2] input"); + await click(document.body, ".datepicker [data-day='12/31/2018']"); + await click(document.body, ".picker-switch [data-action=togglePicker]"); + await click(document.body, ".timepicker [data-action=incrementHours]"); + await click(document.body, ".timepicker [data-action=incrementMinutes]"); + await click(document.body, ".timepicker [data-action=incrementSeconds]"); + await click(document.body, ".picker-switch [data-action=close]"); + assert.equal( + target.querySelector("[property-name=property_2] input").value, + "12/31/2018 12:01:01" + ); + + // save + assert.verifySteps([]); + await click(target, ".o_form_button_save"); + assert.verifySteps(["write", "read"]); + }); });