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"]); + }); });