diff --git a/addons/hr_holidays/static/src/tours/hr_holidays_tour.js b/addons/hr_holidays/static/src/tours/hr_holidays_tour.js index e0e5c8898aa..0e25e70c81a 100644 --- a/addons/hr_holidays/static/src/tours/hr_holidays_tour.js +++ b/addons/hr_holidays/static/src/tours/hr_holidays_tour.js @@ -37,13 +37,13 @@ registry.category("web_tour.tours").add('hr_holidays_tour', { in_modal: false, }, { - trigger: '.o_field_widget[name="request_date_from"] input', + trigger: 'input[data-field=request_date_from]', content: _t("You can select the period you need to take off, from start date to end date"), position: 'right', run: `text ${leaveDateFrom}`, }, { - trigger: '.o_field_widget[name="request_date_to"] input', + trigger: 'input[data-field=request_date_to]', content: _t("You can select the period you need to take off, from start date to end date"), position: 'right', run: `text ${leaveDateTo}`, diff --git a/addons/note/static/src/web/activity/activity_menu_patch.js b/addons/note/static/src/web/activity/activity_menu_patch.js index 95cb66b36e7..474dadf10e7 100644 --- a/addons/note/static/src/web/activity/activity_menu_patch.js +++ b/addons/note/static/src/web/activity/activity_menu_patch.js @@ -1,11 +1,11 @@ /** @odoo-module */ -import { ActivityMenu } from "@mail/web/activity/activity_menu"; -import { patch } from "@web/core/utils/patch"; -import { useRef, useState, useEffect } from "@odoo/owl"; -import { useService } from "@web/core/utils/hooks"; -import { DatePicker } from "@web/core/datepicker/datepicker"; import { onExternalClick } from "@mail/utils/hooks"; +import { ActivityMenu } from "@mail/web/activity/activity_menu"; +import { useEffect, useRef, useState } from "@odoo/owl"; +import { DateTimeInput } from "@web/core/datetime/datetime_input"; +import { useService } from "@web/core/utils/hooks"; +import { patch } from "@web/core/utils/patch"; patch(ActivityMenu.prototype, "note", { setup() { @@ -25,7 +25,7 @@ patch(ActivityMenu.prototype, "note", { onExternalClick("noteInput", (ev) => { if ( ev.target.closest(".o-mail-ActivityMenu-show") || - ev.target.closest(".bootstrap-datetimepicker-widget") + ev.target.closest(".o_datetime_picker") ) { return; } @@ -69,5 +69,5 @@ patch(ActivityMenu.prototype, "note", { ActivityMenu.components = { ...ActivityMenu.components, - DatePicker, + DateTimeInput, }; diff --git a/addons/note/static/src/web/activity/activity_menu_patch.xml b/addons/note/static/src/web/activity/activity_menu_patch.xml index 86567ac2355..f23e8c63e94 100644 --- a/addons/note/static/src/web/activity/activity_menu_patch.xml +++ b/addons/note/static/src/web/activity/activity_menu_patch.xml @@ -10,9 +10,10 @@
Add a note -
diff --git a/addons/note/static/tests/activity_menu_tests.js b/addons/note/static/tests/activity_menu_tests.js index e94a4b9dd3b..7f76c4d67fe 100644 --- a/addons/note/static/tests/activity_menu_tests.js +++ b/addons/note/static/tests/activity_menu_tests.js @@ -55,7 +55,7 @@ QUnit.test("create note from activity menu with date", async function (assert) { await editInput(target, "input.o-mail-ActivityMenu-input", "New Note"); await editInput( target, - "input.o_datepicker_input", + "input.o_datetime_input", futureDay.toString(luxon.DateTime.DATE_SHORT) ); await click("button:contains(SAVE)"); diff --git a/addons/project/static/tests/tours/project_update_tour_tests.js b/addons/project/static/tests/tours/project_update_tour_tests.js index 716f5d5a5d1..958c6926fd0 100644 --- a/addons/project/static/tests/tours/project_update_tour_tests.js +++ b/addons/project/static/tests/tours/project_update_tour_tests.js @@ -106,7 +106,7 @@ registry.category("web_tour.tours").add('project_update_tour', { trigger: "div.o_field_widget[name=name] input", run: 'text New milestone' }, { - trigger: "div[name=deadline] .datetimepicker-input", + trigger: "input[data-field=deadline]", run: 'text 12/12/2099' }, { trigger: ".modal-footer .o_form_button_save" @@ -116,14 +116,14 @@ registry.category("web_tour.tours").add('project_update_tour', { trigger: "div.o_field_widget[name=name] input", run: 'text Second milestone' }, { - trigger: "div[name=deadline] .datetimepicker-input", + trigger: "input[data-field=deadline]", run: 'text 12/12/2022' }, { trigger: ".modal-footer .o_form_button_save" }, { trigger: ".o_rightpanel_milestone:eq(1) .o_milestone_detail", }, { - trigger: "div[name=deadline] .datetimepicker-input", + trigger: "input[data-field=deadline]", run: 'text 12/12/2100' }, { trigger: ".modal-footer .o_form_button_save" diff --git a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js index d6521b33e9a..10468ab7063 100644 --- a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js +++ b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js @@ -261,7 +261,7 @@ registry.category("web_tour.tours").add('sale_timesheet_tour', { content: "Edit new Milestone", run: 'text New milestone', }, { - trigger: "div[name=deadline] .datetimepicker-input", + trigger: "input[data-field=deadline]", content: "Edit new Milestone", run: 'text 12/12/2099', }, { diff --git a/addons/spreadsheet/static/src/global_filters/components/filter_date_value/filter_date_value.js b/addons/spreadsheet/static/src/global_filters/components/filter_date_value/filter_date_value.js index 782aaf4603c..d84fa488faf 100644 --- a/addons/spreadsheet/static/src/global_filters/components/filter_date_value/filter_date_value.js +++ b/addons/spreadsheet/static/src/global_filters/components/filter_date_value/filter_date_value.js @@ -1,10 +1,10 @@ /** @odoo-module */ -import { YearPicker } from "../year_picker"; +import { Component, onWillUpdateProps } from "@odoo/owl"; import { dateOptions } from "@spreadsheet/global_filters/helpers"; +import { DateTimeInput } from "@web/core/datetime/datetime_input"; const { DateTime } = luxon; -import { Component, onWillUpdateProps } from "@odoo/owl"; export class DateFilterValue extends Component { setup() { @@ -57,7 +57,7 @@ export class DateFilterValue extends Component { } } DateFilterValue.template = "spreadsheet_edition.DateFilterValue"; -DateFilterValue.components = { YearPicker }; +DateFilterValue.components = { DateTimeInput }; DateFilterValue.props = { // See @spreadsheet_edition/bundle/global_filters/filters_plugin.RangeType diff --git a/addons/spreadsheet/static/src/global_filters/components/filter_date_value/filter_date_value.xml b/addons/spreadsheet/static/src/global_filters/components/filter_date_value/filter_date_value.xml index 1048cf300cd..fb5863cc816 100644 --- a/addons/spreadsheet/static/src/global_filters/components/filter_date_value/filter_date_value.xml +++ b/addons/spreadsheet/static/src/global_filters/components/filter_date_value/filter_date_value.xml @@ -13,10 +13,13 @@ -
diff --git a/addons/spreadsheet/static/src/global_filters/components/year_picker.js b/addons/spreadsheet/static/src/global_filters/components/year_picker.js deleted file mode 100644 index 390e03970fa..00000000000 --- a/addons/spreadsheet/static/src/global_filters/components/year_picker.js +++ /dev/null @@ -1,97 +0,0 @@ -/** @odoo-module */ - -import { DatePicker } from "@web/core/datepicker/datepicker"; -const { DateTime } = luxon; - -const DEFAULT_DATE = DateTime.local(); -export class YearPicker extends DatePicker { - /** - * @override - */ - initFormat() { - super.initFormat(); - // moment.js format - this.defaultFormat = "yyyy"; - this.staticFormat = "yyyy"; - } - - /** - * @override - */ - getOptions(useStatic = false) { - return { - format: - !useStatic || this.isValidStaticFormat(this.format) ? this.format : this.staticFormat, - locale: DEFAULT_DATE.locale, - }; - } - - /** - * @override - */ - bootstrapDateTimePicker(commandOrParams) { - if (typeof commandOrParams === "object") { - const widgetParent = window.$(this.rootRef.el); - commandOrParams = { ...commandOrParams, widgetParent }; - } - super.bootstrapDateTimePicker(commandOrParams); - } - - /** - * @override - */ - onWillUpdateProps(nextProps) { - const pickerParams = {}; - let shouldUpdateInput = false; - for (const prop in nextProps) { - const prev = this.props[prop]; - const next = nextProps[prop]; - if ( - (prev instanceof DateTime && next instanceof DateTime && !prev.equals(next)) || - prev !== next - ) { - pickerParams[prop] = nextProps[prop]; - if (prop === "date") { - this.setDateAndFormat(nextProps); - shouldUpdateInput = true; - } - } - } - if (shouldUpdateInput) { - this.updateInput(); - } - this.bootstrapDateTimePicker(pickerParams); - } - /** - * @override: allow displaying empty dates - */ - updateInput({ useStatic } = {}) { - const [formattedValue] = this.formatValue(this.date, this.getOptions(useStatic)); - this.inputRef.el.value = formattedValue || this.props.placeholder; - } - - /** - * @override - */ - onDateChange({ useStatic } = {}) { - const [date] = this.parseValue(this.inputRef.el.value, this.getOptions(useStatic)); - if (!date || (this.date instanceof DateTime && date.equals(this.date))) { - this.updateInput(); - } else { - this.state.warning = date > DateTime.local(); - this.props.onDateTimeChanged(date); - } - } -} - -const props = { - ...DatePicker.props, - date: { type: DateTime, optional: true }, -}; -delete props["format"]; - -YearPicker.props = props; - -YearPicker.defaultProps = { - ...DatePicker.defaultProps, -}; diff --git a/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_action_test.js b/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_action_test.js index 7384c3c2a96..fdb2caac705 100644 --- a/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_action_test.js +++ b/addons/spreadsheet_dashboard/static/tests/dashboard/dashboard_action_test.js @@ -174,12 +174,10 @@ QUnit.test( const serverData = getServerData(spreadsheetData); const fixture = getFixture(); await createSpreadsheetDashboard({ serverData }); - const year = fixture.querySelector(".o_cp_top_right input.o_datepicker_input"); + const year = fixture.querySelector(".o_cp_top_right input.o_datetime_input"); const this_year = luxon.DateTime.local().year; assert.equal(year.value, String(this_year)); - const input = fixture.querySelector( - "input.o_datepicker_input.o_input.datetimepicker-input" - ); + const input = fixture.querySelector("input.o_datetime_input"); await click(input); await editInput(input, null, String(this_year - 1)); await nextTick(); @@ -189,6 +187,6 @@ QUnit.test( await click(fixture.querySelector(".o_cp_top_right .fa-times")); assert.containsNone(fixture, ".o_cp_top_right .fa-times"); - assert.equal(year.value, "Select year..."); + assert.equal(year.placeholder, "Select year..."); } ); diff --git a/addons/test_mail/static/tests/tracking_value_tests.js b/addons/test_mail/static/tests/tracking_value_tests.js index 2f25d52a7ac..41bd0949dbd 100644 --- a/addons/test_mail/static/tests/tracking_value_tests.js +++ b/addons/test_mail/static/tests/tracking_value_tests.js @@ -228,7 +228,7 @@ QUnit.test( }); await this.start({ res_id: mailTestTrackAllId1 }); await testUtils.fields.editAndTrigger( - target.querySelector("div[name=date_field] .o_datepicker .o_datepicker_input"), + target.querySelector("div[name=date_field] input"), "12/14/2018", ["change"] ); @@ -249,7 +249,7 @@ QUnit.test( }); await this.start({ res_id: mailTestTrackAllId1 }); await testUtils.fields.editAndTrigger( - target.querySelector("div[name=date_field] .o_datepicker .o_datepicker_input"), + target.querySelector("div[name=date_field] input"), "", ["change"] ); @@ -271,7 +271,7 @@ QUnit.test( }); await this.start({ res_id: mailTestTrackAllId1 }); await testUtils.fields.editAndTrigger( - target.querySelector("div[name=datetime_field] .o_datepicker .o_datepicker_input"), + target.querySelector("div[name=datetime_field] input"), "12/14/2018 13:42:28", ["change"] ); @@ -297,7 +297,7 @@ QUnit.test( }); await this.start({ res_id: mailTestTrackAllId1 }); await testUtils.fields.editAndTrigger( - target.querySelector("div[name=datetime_field] .o_datepicker .o_datepicker_input"), + target.querySelector("div[name=datetime_field] input"), "", ["change"] ); diff --git a/addons/web/static/src/core/datepicker/datepicker.js b/addons/web/static/src/core/datepicker/datepicker.js deleted file mode 100644 index bb101000db8..00000000000 --- a/addons/web/static/src/core/datepicker/datepicker.js +++ /dev/null @@ -1,354 +0,0 @@ -/** @odoo-module **/ - -import { - areDateEquals, - formatDate, - formatDateTime, - luxonToMoment, - luxonToMomentFormat, - momentToLuxon, - parseDate, - parseDateTime, -} from "@web/core/l10n/dates"; -import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; -import { localization } from "@web/core/l10n/localization"; -import { useAutofocus } from "@web/core/utils/hooks"; -import { isMobileOS } from "@web/core/browser/feature_detection"; - -import { - Component, - onMounted, - onWillUpdateProps, - onWillUnmount, - useExternalListener, - useRef, - useState, -} from "@odoo/owl"; -const { DateTime } = luxon; - -let datePickerId = 0; - -/** - * @param {Function} fn - * @returns {[any, null] | [null, Error]} - */ -function wrapError(fn) { - return (...args) => { - const result = [null, null]; - try { - result[0] = fn(...args); - } catch (_err) { - result[1] = _err; - } - return result; - }; -} - -/** - * Date picker - * - * This component exposes the API of the tempusdominus datepicker library. - * As such, its template is a simple input that will open the TD datepicker - * when clicked on. The component will also synchronize any user-input value - * with the library widget and vice-versa. - * - * Note that all props given to this component will be passed as arguments to - * instantiate the picker widget. Also any luxon date is automatically - * stringified since tempusdominus only works with moment objects. - * @extends Component - */ -export class DatePicker extends Component { - setup() { - this.rootRef = useRef("root"); - this.inputRef = useRef("input"); - this.state = useState({ warning: false }); - - this.datePickerId = `o_datepicker_${datePickerId++}`; - // Manually keep track of the "open" state to write the date in the - // static format just before bootstrap parses it. - this.datePickerShown = false; - - this.initFormat(); - this.setDateAndFormat(this.props); - - useAutofocus(); - useExternalListener(window, "scroll", this.onWindowScroll, { capture: true }); - - onMounted(this.onMounted); - onWillUpdateProps(this.onWillUpdateProps); - onWillUnmount(this.onWillUnmount); - } - - onMounted() { - this.bootstrapDateTimePicker(this.props); - this.updateInput(); - - window.$(this.rootRef.el).on("show.datetimepicker", () => { - this.datePickerShown = true; - this.inputRef.el.select(); - }); - window.$(this.rootRef.el).on("hide.datetimepicker", ({date}) => { - this.datePickerShown = false; - this.onDateChange({ eventDate: date, useStatic: true }); - }); - window.$(this.rootRef.el).on("error.datetimepicker", () => false); - } - - onWillUpdateProps(nextProps) { - const pickerParams = {}; - for (const prop in nextProps) { - if (!areDateEquals(this.props[prop], nextProps[prop])) { - pickerParams[prop] = nextProps[prop]; - } - } - this.setDateAndFormat(nextProps); - if ("date" in pickerParams || "format" in pickerParams) { - this.updateInput(); - } - this.bootstrapDateTimePicker(pickerParams); - } - - onWillUnmount() { - window.$(this.rootRef.el).off(); // Removes all jQuery events - this.bootstrapDateTimePicker("destroy"); - } - - //--------------------------------------------------------------------- - // Protected - //--------------------------------------------------------------------- - - /** - * @param {string} format - * @returns {boolean} - */ - isValidStaticFormat(format) { - return /^[\d\s\/.,:-]+$/.test(DateTime.local().toFormat(format)); - } - - getOptions(useStatic = false) { - return { - format: - !useStatic || this.isValidStaticFormat(this.format) ? this.format : this.staticFormat, - locale: this.props.locale || (this.date && this.date.locale), - }; - } - - /** - * Initialises formatting and parsing parameters - */ - initFormat() { - this.defaultFormat = localization.dateFormat; - this.staticFormat = "yyyy/MM/dd"; - this.formatValue = wrapError(formatDate); - this.parseValue = wrapError(parseDate); - this.isLocal = false; - } - - /** - * Sets the current date value. If a locale is provided, the given date - * will first be set in that locale. - * @param {Object} params - * @param {DateTime} params.date - * @param {string} [params.locale] - * @param {string} [params.format] - */ - setDateAndFormat({ date, locale, format }) { - this.date = date && locale ? date.setLocale(locale) : date; - // Fallback to default localization format in `@web/core/l10n/dates.js`. - this.format = format || this.defaultFormat; - } - - /** - * Updates the input element with the current formatted date value. - * @param {Object} [params={}] - * @param {boolean} [params.useStatic] - */ - updateInput({ useStatic } = {}) { - const [formattedDate] = this.formatValue(this.date, this.getOptions(useStatic)); - if (formattedDate !== null) { - this.inputRef.el.value = formattedDate; - this.props.onUpdateInput(formattedDate); - } - } - - //--------------------------------------------------------------------- - // Bootstrap datepicker - //--------------------------------------------------------------------- - - /** - * Handles bootstrap datetimepicker calls. - * @param {string | Object} commandOrParams - */ - bootstrapDateTimePicker(commandOrParams) { - if (typeof commandOrParams === "object") { - const format = this.isValidStaticFormat(this.format) ? this.format : this.staticFormat; - const params = { - ...commandOrParams, - date: this.date || null, - format: luxonToMomentFormat(format), - locale: commandOrParams.locale || (this.date && this.date.locale), - }; - for (const prop in params) { - if (params[prop] instanceof DateTime) { - params[prop] = luxonToMoment(params[prop]); - } - } - commandOrParams = params; - } - return window.$(this.rootRef.el).datetimepicker(commandOrParams); - } - - //--------------------------------------------------------------------- - // Handlers - //--------------------------------------------------------------------- - - onInputClick() { - if (!this.datePickerShown) { - this.updateInput({ useStatic: true }); - } - this.bootstrapDateTimePicker("toggle"); - } - - /** - * Called either when the input value has changed or when the boostrap - * datepicker is closed. The onDateTimeChanged prop is only called if the - * date value has changed. - * @param {Object} [params={}] - * @param {boolean} [params.useStatic] - */ - onDateChange({ eventDate, useStatic } = {}) { - const { value } = this.inputRef.el; - let parsedDate = value && eventDate ? momentToLuxon(eventDate).setLocale(this.getOptions().locale): null; - if (!parsedDate) { - const options = this.getOptions(useStatic); - parsedDate = this.parseValue(value, options)[0]; - } - this.state.warning = parsedDate && parsedDate > DateTime.local(); - // Always update input. - // if the date is invalid, it will reset to default (= given) date. - // if the input is a computed date (+5d for instance), it will put the correct date. - this.updateInput(); - if (parsedDate !== null && !areDateEquals(this.date, parsedDate)) { - this.props.onDateTimeChanged(parsedDate); - } - } - - onInputKeydown(ev) { - const hotkey = getActiveHotkey(ev); - if (this.datePickerShown && hotkey === "escape") { - this.bootstrapDateTimePicker("hide"); - this.inputRef.el.select(); - ev.preventDefault(); - ev.stopPropagation(); - } - } - - /** - * @param {Event} ev - */ - onWindowScroll(ev) { - if (!isMobileOS() && ev.target !== this.inputRef.el) { - this.bootstrapDateTimePicker("hide"); - } - } -} - -DatePicker.defaultProps = { - calendarWeeks: true, - icons: { - clear: "fa fa-delete", - close: "fa fa-check primary", - date: "fa fa-calendar", - down: "fa fa-chevron-down", - next: "fa fa-chevron-right", - previous: "fa fa-chevron-left", - time: "fa fa-clock-o", - today: "fa fa-calendar-check-o", - up: "fa fa-chevron-up", - }, - inputId: "", - maxDate: DateTime.fromObject({ year: 9999, month: 12, day: 31 }), - minDate: DateTime.fromObject({ year: 1000 }), - useCurrent: false, - widgetParent: "body", - onInput: () => {}, - onUpdateInput: () => {}, -}; -DatePicker.props = { - // Components props - onDateTimeChanged: Function, - date: { type: [DateTime, { value: false }], optional: true }, - warn_future: { type: Boolean, optional: true }, - // Bootstrap datepicker options - buttons: { - type: Object, - shape: { - showClear: Boolean, - showClose: Boolean, - showToday: Boolean, - }, - optional: true, - }, - calendarWeeks: { type: Boolean, optional: true }, - format: { type: String, optional: true }, - icons: { - type: Object, - shape: { - clear: String, - close: String, - date: String, - down: String, - next: String, - previous: String, - time: String, - today: String, - up: String, - }, - optional: true, - }, - inputId: { type: String, optional: true }, - keyBinds: { validate: (kb) => typeof kb === "object" || kb === null, optional: true }, - locale: { type: String, optional: true }, - maxDate: { type: DateTime, optional: true }, - minDate: { type: DateTime, optional: true }, - readonly: { type: Boolean, optional: true }, - useCurrent: { type: Boolean, optional: true }, - widgetParent: { type: String, optional: true }, - daysOfWeekDisabled: { type: Array, optional: true }, - placeholder: { type: String, optional: true }, - onInput: { type: Function, optional: true }, - onUpdateInput: { type: Function, optional: true }, -}; -DatePicker.template = "web.DatePicker"; - -/** - * Date/time picker - * - * Similar to the DatePicker component, adding the handling of more specific - * time values: hour-minute-second. - * - * Once again, refer to the tempusdominus documentation for implementation - * details. - * @extends DatePicker - */ -export class DateTimePicker extends DatePicker { - /** - * @override - */ - initFormat() { - this.defaultFormat = localization.dateTimeFormat; - this.staticFormat = "yyyy/MM/dd HH:mm:ss"; - this.formatValue = wrapError(formatDateTime); - this.parseValue = wrapError(parseDateTime); - this.isLocal = true; - } -} - -DateTimePicker.defaultProps = { - ...DatePicker.defaultProps, - buttons: { - showClear: false, - showClose: true, - showToday: false, - }, -}; diff --git a/addons/web/static/src/core/datepicker/datepicker.xml b/addons/web/static/src/core/datepicker/datepicker.xml deleted file mode 100644 index 99a82e6924c..00000000000 --- a/addons/web/static/src/core/datepicker/datepicker.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - -
- - - -
-
- -
diff --git a/addons/web/static/src/core/datetime/datetime_hook.js b/addons/web/static/src/core/datetime/datetime_hook.js new file mode 100644 index 00000000000..ddce0c2eec4 --- /dev/null +++ b/addons/web/static/src/core/datetime/datetime_hook.js @@ -0,0 +1,393 @@ +/** @odoo-module **/ + +import { + onPatched, + onWillStart, + onWillUpdateProps, + reactive, + useComponent, + useEffect, + useEnv, + useRef, +} from "@odoo/owl"; +import { areDatesEqual } from "../l10n/dates"; +import { usePopover } from "../popover/popover_hook"; +import { registry } from "../registry"; +import { ensureArray, zip, zipWith } from "../utils/arrays"; +import { DateTimePicker } from "./datetime_picker"; +import { DateTimePickerPopover } from "./datetime_picker_popover"; + +/** + * @typedef {luxon.DateTime} DateTime + * + * @typedef DateTimePickerHookParams + * @property {string} [format] + * @property {(value: DateTimePickerProps["value"]) => any} [onChange] + * @property {(value: DateTimePickerProps["value"]) => any} [onApply] + * @property {DateTimePickerProps | (props: Record) => PromiseLike} [pickerProps] + * @property {string | ReturnType} [target] + * + * @typedef {import("./datetime_picker").DateTimePickerProps} DateTimePickerProps + */ + +/** + * @template {HTMLElement} T + * @typedef {{ el: T | null }} OwlRef + */ + +const FOCUS_CLASSNAME = "text-primary"; + +/** + * @param {DateTimePickerHookParams} hookParams + */ +export const useDateTimePicker = (hookParams) => { + // Hook methods + + /** + * @param {HTMLElement} el + * @param {string} type + * @param {(ev: Event) => any} listener + */ + const addListener = (el, type, listener) => { + el.addEventListener(type, listener); + cleanups.push(() => el.removeEventListener(type, listener)); + }; + + /** + * Wrapper method on the "onApply" callback to only call it when the + * value has changed, and set other internal variables accordingly. + */ + const checkAndApply = () => { + if (areDatesEqual(pickerProps.value, lastAppliedDate)) { + return; + } + lastAppliedDate = pickerProps.value; + inputsChanged = ensureArray(pickerProps.value).map(() => false); + onApply?.(pickerProps.value); + }; + + /** + * Ensures the given input has the focus and applies the class names on all + * inputs accordingly. + * + * @param {HTMLInputElement} inputEl + */ + const focusInput = (inputEl) => { + const { activeElement } = inputEl.ownerDocument; + if (activeElement !== inputEl) { + inputEl.focus(); + } + inputEl.selectionStart = 0; + inputEl.selectionEnd = inputEl.value.length; + setFocusClass(inputEl); + }; + + /** + * @param {number} valueIndex + * @returns + */ + const getInput = (valueIndex) => { + const el = inputRefs[valueIndex]?.el; + if (el && document.body.contains(el)) { + return el; + } + return null; + }; + + /** + * Returns the appropriate root element to attach the popover: + * - if the value is a range: the closest common parent of the two inputs + * - if not: the first input + */ + const getPopoverTarget = () => { + if (targetRef?.el) { + return targetRef.el; + } + if (isRange(pickerProps.value)) { + let parentElement = getInput(0).parentElement; + const inputEls = inputRefs.map((ref) => ref.el); + while (parentElement && !inputEls.every((inputEl) => parentElement.contains(inputEl))) { + parentElement = parentElement.parentElement; + } + return parentElement || getInput(0); + } else { + return getInput(0); + } + }; + + /** + * @param {unknown} [value] + * @returns {boolean} + */ + const isRange = (value) => Array.isArray(value); + + /** + * Inputs "change" event handler. This will trigger an "onApply" callback if + * one of the following is true: + * - there is only one input; + * - the popover is closed; + * - the other input has also changed. + * + * @param {Event} ev + */ + const onInputChange = (ev) => { + updateValueFromInputs(); + inputsChanged[ev.target === getInput(1) ? 1 : 0] = true; + if (!popover.isOpen || inputsChanged.every(Boolean)) { + saveAndClose(); + } + }; + + /** + * @param {KeyboardEvent} ev + */ + const onInputKeydown = (ev) => { + switch (ev.key) { + case "Enter": + case "Escape": { + ev.preventDefault(); + return saveAndClose(); + } + case "Tab": { + updateValueFromInputs(); + if (!getInput(0) || !getInput(1) || ev.target !== getInput(ev.shiftKey ? 1 : 0)) { + return saveAndClose(); + } + } + } + }; + + /** + * @param {DateTimePickerProps} nextProps + */ + const onPropsUpdated = (nextProps) => { + const { pickerProps: getPickerProps } = hookParams; + const nextPickerProps = + typeof getPickerProps === "function" + ? getPickerProps(nextProps || comp.props) + : getPickerProps; + + canNotify = false; + Object.assign(pickerProps, nextPickerProps); + canNotify = true; + + lastChangedDate = lastAppliedDate = pickerProps.value; + inputsChanged = ensureArray(pickerProps.value).map(() => false); + }; + + /** + * @param {HTMLInputElement} inputEl + */ + const openFromInput = (inputEl) => { + pickerProps.focusedDateIndex = inputEl === getInput(1) ? 1 : 0; + + if (!popover.isOpen) { + if (inputEl && env.isSmall) { + inputEl.scrollIntoView(true); + } + + popover.open(getPopoverTarget(), { pickerProps }); + } + + focusInput(inputEl); + }; + + /** + * @template {"format" | "parse"} T + * @param {T} operation + * @param {T extends "format" ? DateTime : string} value + * @returns {[T extends "format" ? string : DateTime, null] | [null, Error]} + */ + const safeConvert = (operation, value) => { + const { type } = pickerProps; + const convertFn = registry + .category(operation === "format" ? "formatters" : "parsers") + .get(type); + try { + return [convertFn(value, { format }), null]; + } catch (error) { + if (error?.name === "ConversionError") { + return [null, error]; + } else { + throw error; + } + } + }; + + /** + * Wrapper method to ensure the "onApply" callback is called, either: + * - by closing the popover (if any); + * - or by directly calling "checkAndApply", without updating the values. + */ + const saveAndClose = () => { + if (popover.isOpen) { + // check & apply will be done in the "onClose" callback + popover.close(); + } else { + checkAndApply(); + } + }; + + /** + * Updates class names on given inputs according to the currently selected input. + * + * @param {HTMLInputElement | null} input + */ + const setFocusClass = (input) => { + for (const { el } of inputRefs) { + if (el) { + el.classList.toggle(FOCUS_CLASSNAME, popover.isOpen && el === input); + } + } + }; + + /** + * Reads every key of the reactive picker props to subscribe to their changes. + */ + const subscribeToPickerProps = () => + Object.keys(pickerProps).forEach((key) => pickerProps[key]); + + /** + * Synchronizes the given input with the given value. + * + * @param {HTMLInputElement} el + * @param {DateTime} value + */ + const updateInput = (el, value) => { + const [formattedValue] = safeConvert("format", value); + el.value = formattedValue || ""; + }; + + /** + * @param {DateTimePickerProps["value"]} value + */ + const updateValue = (value) => { + if (isRange(pickerProps.value)) { + // When in range: compare each individual value + const [currentStart, currentEnd] = ensureArray(pickerProps.value); + const [nextStart, nextEnd] = ensureArray(value); + const areStartDatesEqual = areDatesEqual(currentStart, nextStart); + const areEndDatesEqual = areDatesEqual(currentEnd, nextEnd); + if ( + (pickerProps.focusedDateIndex === 0 && areEndDatesEqual) || + (pickerProps.focusedDateIndex === 1 && areStartDatesEqual) + ) { + pickerProps.focusedDateIndex = pickerProps.focusedDateIndex === 1 ? 0 : 1; + } + } + pickerProps.value = value; + }; + + const updateValueFromInputs = () => { + const values = zipWith( + inputRefs, + ensureArray(pickerProps.value), + ({ el }, currentValue) => { + if (!el) { + return currentValue; + } + const [parsedValue, error] = safeConvert("parse", el.value); + if (error) { + updateInput(el, currentValue); + return currentValue; + } else { + return parsedValue; + } + } + ); + updateValue(isRange(pickerProps.value) ? values : values[0]); + }; + + // Hook variables + + const { format, onChange, onApply, target } = hookParams; + /** @type {ReturnType>[]} */ + const targetRef = typeof target === "string" ? useRef(target) : target; + const inputRefs = [useRef("start-date"), useRef("end-date")]; + const env = useEnv(); + const comp = useComponent(); + const popover = usePopover(DateTimePickerPopover, { + onClose: () => { + updateValueFromInputs(); + checkAndApply(); + setFocusClass(null); + }, + }); + const rawPickerProps = { + ...DateTimePicker.defaultProps, + onSelect: (value) => { + updateValue(value); + if (!isRange(value) && pickerProps.type === "date") { + saveAndClose(); + } + }, + }; + const pickerProps = reactive(rawPickerProps, () => { + subscribeToPickerProps(); + + // Update inputs + for (const [{ el }, value] of zip(inputRefs, ensureArray(pickerProps.value), true)) { + if (el) { + updateInput(el, value); + } + } + + // Will focus input on next patch + shouldFocus = true; + setFocusClass(getInput(pickerProps.focusedDateIndex)); + + if (!canNotify || areDatesEqual(lastChangedDate, pickerProps.value)) { + return; + } + + lastChangedDate = pickerProps.value; + onChange?.(pickerProps.value); + }); + + let canNotify = false; + let inputsChanged = []; + let lastAppliedDate; + let lastChangedDate; + let shouldFocus = false; + + subscribeToPickerProps(); + + onWillStart(onPropsUpdated); + onWillUpdateProps(onPropsUpdated); + + onPatched(() => { + if (!shouldFocus || !popover.isOpen) { + return; + } + const input = getInput(pickerProps.focusedDateIndex); + if (input) { + shouldFocus = false; + focusInput(input); + } + }); + + const cleanups = []; + useEffect(() => { + let editableInputs = 0; + for (const [{ el }, value] of zip(inputRefs, ensureArray(pickerProps.value), true)) { + if (el && !el.disabled && !el.readOnly) { + addListener(el, "change", onInputChange); + addListener(el, "focus", (ev) => openFromInput(ev.target)); + addListener(el, "keydown", onInputKeydown); + + updateInput(el, value); + editableInputs++; + } + } + if (!editableInputs) { + saveAndClose(); + } + return () => { + while (cleanups.length) { + cleanups.pop()(); + } + }; + }); + + return pickerProps; +}; diff --git a/addons/web/static/src/core/datetime/datetime_input.js b/addons/web/static/src/core/datetime/datetime_input.js new file mode 100644 index 00000000000..cc238bcef98 --- /dev/null +++ b/addons/web/static/src/core/datetime/datetime_input.js @@ -0,0 +1,40 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { omit } from "../utils/objects"; +import { useDateTimePicker } from "./datetime_hook"; +import { DateTimePicker } from "./datetime_picker"; + +/** + * @typedef {import("./datetime_picker").DateTimePickerProps & { + * format?: string; + * id?: string; + * onApply?: (value: DateTime) => any; + * onChange?: (value: DateTime) => any; + * placeholder?: string; + * }} DateTimeInputProps + */ + +/** @extends {Component} */ +export class DateTimeInput extends Component { + static props = { + ...DateTimePicker.props, + format: { type: String, optional: true }, + id: { type: String, optional: true }, + onChange: { type: Function, optional: true }, + onApply: { type: Function, optional: true }, + placeholder: { type: String, optional: true }, + }; + + static template = "web.DateTimeInput"; + + setup() { + useDateTimePicker({ + format: this.props.format, + pickerProps: (props) => + omit(props, "format", "placeholder", "id", "onChange", "onApply"), + onApply: (...args) => this.props.onApply?.(...args), + onChange: (...args) => this.props.onChange?.(...args), + }); + } +} diff --git a/addons/web/static/src/core/datetime/datetime_input.xml b/addons/web/static/src/core/datetime/datetime_input.xml new file mode 100644 index 00000000000..8753b33d507 --- /dev/null +++ b/addons/web/static/src/core/datetime/datetime_input.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/addons/web/static/src/core/datetime/datetime_picker.js b/addons/web/static/src/core/datetime/datetime_picker.js new file mode 100644 index 00000000000..b878e2bb7cd --- /dev/null +++ b/addons/web/static/src/core/datetime/datetime_picker.js @@ -0,0 +1,706 @@ +/** @odoo-module **/ + +import { + Component, + onWillRender, + onWillUpdateProps, + useExternalListener, + useState, +} from "@odoo/owl"; +import { _lt } from "@web/core/l10n/translation"; +import { + MAX_VALID_DATE, + MIN_VALID_DATE, + clampDate, + is24HourFormat, + isInRange, + isMeridiemFormat, + today, +} from "../l10n/dates"; +import { localization } from "../l10n/localization"; +import { ensureArray } from "../utils/arrays"; + +/** + * @typedef DateItem + * @property {string} id + * @property {boolean} includesToday + * @property {boolean} isOutOfRange + * @property {boolean} isInvalid + * @property {string} label + * @property {DateRange} range + * + * @typedef {"today" | NullableDateTime} DateLimit + * + * @typedef {[DateTime, DateTime]} DateRange + * + * @typedef {luxon.DateTime} DateTime + * + * @typedef DateTimePickerProps + * @property {number} [focusedDateIndex=0] + * @property {DateLimit} [maxDate] + * @property {PrecisionLevel} [maxPrecision="decades"] + * @property {DateLimit} [minDate] + * @property {PrecisionLevel} [minPrecision="days"] + * @property {(value: DateTime) => any} [onSelect] + * @property {number} [rounding=5] + * @property {{ buttons?: any }} [slots] + * @property {"date" | "datetime"} [type] + * @property {NullableDateTime | NullableDateRange} value + * + * @typedef {DateItem | MonthItem} Item + * + * @typedef MonthItem + * @property {[string, string][]} daysOfWeek + * @property {string} id + * @property {number} number + * @property {WeekItem[]} weeks + * + * @typedef {import("@web/core/l10n/dates").NullableDateTime} NullableDateTime + * + * @typedef {import("@web/core/l10n/dates").NullableDateRange} NullableDateRange + * + * @typedef PrecisionInfo + * @property {(date: DateTime, params: Partial) => string} getTitle + * @property {(date: DateTime, params: Partial) => Item[]} getItems + * @property {string} mainTitle + * @property {string} nextTitle + * @property {string} prevTitle + * @property {Record} step + * + * @typedef {"days" | "months" | "years" | "decades"} PrecisionLevel + * + * @typedef WeekItem + * @property {DateItem[]} days + * @property {number} number + */ + +const { DateTime } = luxon; + +/** + * @param {DateTime} date + */ +const getStartOfDecade = (date) => Math.floor(date.year / 10) * 10; + +/** + * @param {DateTime} date + */ +const getStartOfCentury = (date) => Math.floor(date.year / 100) * 100; + +/** + * @param {DateTime} date + */ +const getStartOfWeek = (date) => { + const { weekStart } = localization; + return date.set({ weekday: date.weekday < weekStart ? weekStart - 7 : weekStart }); +}; + +/** + * @param {number} min + * @param {number} max + */ +const numberRange = (min, max) => [...Array(max - min)].map((_, i) => i + min); + +/** + * @param {NullableDateTime | "today"} value + * @param {NullableDateTime | "today"} defaultValue + */ +const parseLimitDate = (value, defaultValue) => + clampDate(value === "today" ? today() : value || defaultValue, MIN_VALID_DATE, MAX_VALID_DATE); + +/** + * @param {Object} params + * @param {boolean} [params.isOutOfRange=false] + * @param {boolean} [params.isInvalid=false] + * @param {keyof DateTime} params.label + * @param {[DateTime, DateTime]} params.range + * @returns {DateItem} + */ +const toDateItem = ({ isOutOfRange = false, isInvalid = false, label, range }) => ({ + id: range[0].toISODate(), + includesToday: isInRange(today(), range), + isOutOfRange, + isInvalid, + label: String(range[0][label]), + range, +}); + +/** + * @param {DateItem[]} weekDayItems + * @returns {WeekItem} + */ +const toWeekItem = (weekDayItems) => ({ + number: weekDayItems[3].range[0].weekNumber, + days: weekDayItems, +}); + +// Time constants +const HOURS = numberRange(0, 24).map((hour) => [hour, String(hour)]); +const MINUTES = numberRange(0, 60).map((minute) => [minute, String(minute || 0).padStart(2, "0")]); +const MERIDIEMS = ["AM", "PM"]; + +/** + * Precision levels + * @type {Map} + */ +const PRECISION_LEVELS = new Map() + .set("days", { + mainTitle: _lt("Select month"), + nextTitle: _lt("Next month"), + prevTitle: _lt("Previous month"), + step: { month: 1 }, + getTitle: (date, { additionalMonth }) => { + const titles = [`${date.monthLong} ${date.year}`]; + if (additionalMonth) { + const next = date.plus({ month: 1 }); + titles.push(`${next.monthLong} ${next.year}`); + } + return titles; + }, + getItems: (date, { additionalMonth, maxDate, minDate, showWeekNumbers }) => { + const startDates = [date]; + if (additionalMonth) { + startDates.push(date.plus({ month: 1 })); + } + return startDates.map((date, i) => { + const monthRange = [date.startOf("month"), date.endOf("month")]; + /** @type {WeekItem[]} */ + const weeks = []; + + // Generate 6 weeks for current month + let startOfNextWeek = getStartOfWeek(monthRange[0]); + for (let w = 0; w < 6; w++) { + const weekDayItems = []; + // Generate all days of the week + for (let d = 0; d < 7; d++) { + const day = startOfNextWeek.plus({ day: d }); + const dayItem = toDateItem({ + isOutOfRange: !isInRange(day, monthRange), + isInvalid: !isInRange(day, [minDate, maxDate]), + label: "day", + range: [day, day.endOf("day")], + }); + weekDayItems.push(dayItem); + if (d === 6) { + startOfNextWeek = day.plus({ day: 1 }); + } + } + weeks.push(toWeekItem(weekDayItems)); + } + + // Generate days of week labels + const daysOfWeek = weeks[0].days.map((d) => [ + d.range[0].weekdayShort, + d.range[0].weekdayLong, + ]); + if (showWeekNumbers) { + daysOfWeek.unshift(["#", _lt("Week numbers")]); + } + + return { + id: `__month__${i}`, + number: monthRange[0].month, + daysOfWeek, + weeks, + }; + }); + }, + }) + .set("months", { + mainTitle: _lt("Select year"), + nextTitle: _lt("Next year"), + prevTitle: _lt("Previous year"), + step: { year: 1 }, + getTitle: (date) => String(date.year), + getItems: (date, { maxDate, minDate }) => { + const startOfYear = date.startOf("year"); + return numberRange(0, 12).map((i) => { + const startOfMonth = startOfYear.plus({ month: i }); + return toDateItem({ + isInvalid: !isInRange(startOfMonth, [minDate, maxDate]), + label: "monthShort", + range: [startOfMonth, startOfMonth.endOf("month")], + }); + }); + }, + }) + .set("years", { + mainTitle: _lt("Select decade"), + nextTitle: _lt("Next decade"), + prevTitle: _lt("Previous decade"), + step: { year: 10 }, + getTitle: (date) => `${getStartOfDecade(date) - 1} - ${getStartOfDecade(date) + 10}`, + getItems: (date, { maxDate, minDate }) => { + const startOfDecade = date.startOf("year").set({ year: getStartOfDecade(date) }); + return numberRange(-GRID_MARGIN, GRID_COUNT + GRID_MARGIN).map((i) => { + const startOfYear = startOfDecade.plus({ year: i }); + return toDateItem({ + isOutOfRange: i < 0 || i >= GRID_COUNT, + isInvalid: !isInRange(startOfYear, [minDate, maxDate]), + label: "year", + range: [startOfYear, startOfYear.endOf("year")], + }); + }); + }, + }) + .set("decades", { + mainTitle: _lt("Select century"), + nextTitle: _lt("Next century"), + prevTitle: _lt("Previous century"), + step: { year: 100 }, + getTitle: (date) => `${getStartOfCentury(date) - 10} - ${getStartOfCentury(date) + 100}`, + getItems: (date, { maxDate, minDate }) => { + const startOfCentury = date.startOf("year").set({ year: getStartOfCentury(date) }); + return numberRange(-GRID_MARGIN, GRID_COUNT + GRID_MARGIN).map((i) => { + const startOfDecade = startOfCentury.plus({ year: i * 10 }); + return toDateItem({ + label: "year", + isOutOfRange: i < 0 || i >= GRID_COUNT, + isInvalid: !isInRange(startOfDecade, [minDate, maxDate]), + range: [startOfDecade, startOfDecade.plus({ year: 10, millisecond: -1 })], + }); + }); + }, + }); + +// Other constants +const GRID_COUNT = 10; +const GRID_MARGIN = 1; +const NULLABLE_DATETIME_PROPERTY = [DateTime, { value: false }, { value: null }]; + +/** @extends {Component} */ +export class DateTimePicker extends Component { + static props = { + focusedDateIndex: { type: Number, optional: true }, + maxDate: { type: [NULLABLE_DATETIME_PROPERTY, { value: "today" }], optional: true }, + maxPrecision: { + type: [...PRECISION_LEVELS.keys()].map((value) => ({ value })), + optional: true, + }, + minDate: { type: [NULLABLE_DATETIME_PROPERTY, { value: "today" }], optional: true }, + minPrecision: { + type: [...PRECISION_LEVELS.keys()].map((value) => ({ value })), + optional: true, + }, + onSelect: { type: Function, optional: true }, + rounding: { type: Number, optional: true }, + slots: { + type: Object, + shape: { buttons: { type: Object, optional: true } }, + optional: true, + }, + type: { type: [{ value: "date" }, { value: "datetime" }], optional: true }, + value: { + type: [ + NULLABLE_DATETIME_PROPERTY, + { + type: Array, + element: NULLABLE_DATETIME_PROPERTY, + validate: (values) => values.length === 2, + }, + ], + optional: true, + }, + }; + + static defaultProps = { + focusedDateIndex: 0, + maxPrecision: "decades", + minPrecision: "days", + rounding: 5, + type: "datetime", + }; + + static template = "web.DateTimePicker"; + + //------------------------------------------------------------------------- + // Getters + //------------------------------------------------------------------------- + + get activePrecisionLevel() { + return PRECISION_LEVELS.get(this.state.precision); + } + + get isKeynavEnabled() { + return this.isRange || this.props.type === "datetime"; + } + + get isLastPrecisionLevel() { + return ( + this.allowedPrecisionLevels.indexOf(this.state.precision) === + this.allowedPrecisionLevels.length - 1 + ); + } + + get titles() { + return ensureArray(this.title); + } + + //------------------------------------------------------------------------- + // Lifecycle + //------------------------------------------------------------------------- + + setup() { + this.availableHours = HOURS; + this.availableMinutes = MINUTES; + /** @type {PrecisionLevel[]} */ + this.allowedPrecisionLevels = []; + /** @type {Item[]} */ + this.items = []; + this.title = ""; + this.isRange = false; + this.shouldAdjustFocusDate = false; + + this.state = useState({ + /** @type {DateTime | null} */ + focusDate: null, + /** @type {DateTime | null} */ + hoveredDate: null, + /** @type {[number, number, number][]} */ + timeValues: [], + /** @type {PrecisionLevel} */ + precision: this.props.minPrecision, + }); + + this.onPropsUpdated(this.props); + onWillUpdateProps((nextProps) => this.onPropsUpdated(nextProps)); + + onWillRender(() => this.onWillRender()); + + useExternalListener( + window, + "keydown", + (ev) => this.isKeynavEnabled && this.onWindowKeyDown(ev) + ); + } + + /** + * @param {DateTimePickerProps} props + */ + onPropsUpdated(props) { + /** @type {[NullableDateTime] | NullableDateRange} */ + this.values = ensureArray(props.value).map((value) => + value && !value.isValid ? null : value + ); + this.isRange = Array.isArray(props.value); + this.availableHours = HOURS; + this.availableMinutes = MINUTES.filter((minute) => !(minute[0] % props.rounding)); + this.allowedPrecisionLevels = this.filterPrecisionLevels( + props.minPrecision, + props.maxPrecision + ); + + this.additionalMonth = this.isRange && !this.env.isSmall; + this.maxDate = parseLimitDate(props.maxDate, MAX_VALID_DATE).endOf("day"); + this.minDate = parseLimitDate(props.minDate, MIN_VALID_DATE).startOf("day"); + + if (this.maxDate < this.minDate) { + throw new Error(`DateTimePicker error: given "maxDate" comes before "minDate".`); + } + + this.state.timeValues = this.values.map((val) => [ + (val || DateTime.local()).hour, + val?.minute || 0, + val?.second || 0, + ]); + + this.adjustFocus(this.values, props.focusedDateIndex); + this.handle12HourSystem(); + } + + onWillRender() { + const precision = this.activePrecisionLevel; + const getterParams = { + additionalMonth: this.additionalMonth, + maxDate: this.maxDate, + minDate: this.minDate, + showWeekNumbers: !this.isRange, + }; + const referenceDate = this.state.focusDate; + this.title = precision.getTitle(referenceDate, getterParams); + this.items = precision.getItems(referenceDate, getterParams); + } + + //------------------------------------------------------------------------- + // Methods + //------------------------------------------------------------------------- + + /** + * @param {NullableDateTime[]} values + * @param {number} focusedDateIndex + */ + adjustFocus(values, focusedDateIndex) { + if ( + !this.shouldAdjustFocusDate && + this.state.focusDate && + focusedDateIndex === this.props.focusedDateIndex + ) { + return; + } + + let dateToFocus = + values[focusedDateIndex] || values[focusedDateIndex === 1 ? 0 : 1] || today(); + + if ( + this.additionalMonth && + focusedDateIndex === 1 && + values[0] && + values[1] && + values[0].month !== values[1].month + ) { + dateToFocus = dateToFocus.minus({ month: 1 }); + } + + this.shouldAdjustFocusDate = false; + this.state.focusDate = this.clamp(dateToFocus.startOf("month")); + } + + /** + * @param {DateTime} value + */ + clamp(value) { + return clampDate(value, this.minDate, this.maxDate); + } + + /** + * @param {PrecisionLevel} minPrecision + * @param {PrecisionLevel} maxPrecision + */ + filterPrecisionLevels(minPrecision, maxPrecision) { + const levels = [...PRECISION_LEVELS.keys()]; + return levels.slice(levels.indexOf(minPrecision), levels.indexOf(maxPrecision) + 1); + } + + /** + * @param {DateItem} item + */ + getActiveRangeInfo({ range }) { + const values = [...this.values]; + const result = { + isActive: isInRange(values, range), + isStart: false, + isEnd: false, + }; + if (this.isRange) { + // Handle active "start" and "end" dates + if (result.isActive) { + const [start, end] = values; + result.isStart = !start || isInRange(start, range); + result.isEnd = !end || isInRange(end, range); + } + + // Handle preview state + if (this.state.hoveredDate) { + values[this.props.focusedDateIndex] = this.state.hoveredDate; + // TODO: comment this + if (values[0] && values[1] && values[1].ts < values[0].ts) { + values.reverse(); + } + const [selectStart, selectEnd] = values; + if (isInRange(values, range)) { + result.isActive = true; + } + if (!selectStart || isInRange(selectStart, range)) { + result.isStart = true; + } + if (!selectEnd || isInRange(selectEnd, range)) { + result.isEnd = true; + } + } + } else { + result.isStart = result.isEnd = result.isActive; + } + return result; + } + + getTimeValues(valueIndex) { + let [hour, minute, second] = this.state.timeValues[valueIndex].map(Number); + if ( + this.is12HourFormat && + this.meridiems && + this.state.timeValues[valueIndex][3] === "PM" + ) { + hour += 12; + } + return [hour, minute, second]; + } + + handle12HourSystem() { + if (isMeridiemFormat()) { + this.meridiems = MERIDIEMS.map((m) => [m, m]); + for (const timeValues of this.state.timeValues) { + timeValues.push(MERIDIEMS[Math.floor(timeValues[0] / 12) || 0]); + } + } + this.is12HourFormat = !is24HourFormat(); + if (this.is12HourFormat) { + this.availableHours = [[0, HOURS[12][1]], ...HOURS.slice(1, 12)]; + for (const timeValues of this.state.timeValues) { + timeValues[0] %= 12; + } + } + } + + /** + * @param {DateItem} item + */ + isSelectedDate({ range }) { + return this.values.some((value) => isInRange(value, range)); + } + + /** + * Softly moves the selected date by the given duration. + * @param {import("luxon").DurationLike} duration + */ + navigate(duration) { + const { focusedDateIndex } = this.props; + const value = (this.values[focusedDateIndex] || today()).plus(duration); + + this.shouldAdjustFocusDate = this.validateAndSelect(value, focusedDateIndex); + } + + /** + * Goes to the next panel (e.g. next month if precision is "days"). + * If an event is given it will be prevented. + * @param {Event} [ev] + */ + next(ev) { + ev?.preventDefault(); + const { step } = this.activePrecisionLevel; + this.state.focusDate = this.clamp(this.state.focusDate.plus(step)); + } + + /** + * Goes to the previous panel (e.g. previous month if precision is "days"). + * If an event is given it will be prevented. + * @param {Event} [ev] + */ + previous(ev) { + ev?.preventDefault(); + const { step } = this.activePrecisionLevel; + this.state.focusDate = this.clamp(this.state.focusDate.minus(step)); + } + + /** + * Happens when an hour or a minute (or AM/PM if can apply) is selected. + * @param {number} valueIndex + */ + selectTime(valueIndex) { + const value = this.values[valueIndex] || today(); + this.validateAndSelect(value, valueIndex); + } + + /** + * @param {DateTime} value + * @param {number} valueIndex + */ + validateAndSelect(value, valueIndex) { + if (!this.props.onSelect) { + // No onSelect handler + return false; + } + const result = [...this.values]; + if (this.isRange) { + if (value.endOf("day") < result[0]) { + valueIndex = 0; + } else if (value.startOf("day") > result[1]) { + valueIndex = 1; + } + } + if (this.props.type === "datetime") { + // Adjusts result according to the current time values + const [hour, minute, second] = this.getTimeValues(valueIndex); + value = value.set({ hour, minute, second }); + } + if (!isInRange(value, [this.minDate, this.maxDate])) { + // Date is outside range defined by min and max dates + return false; + } + result[valueIndex] = value; + this.props.onSelect(this.isRange ? result : result[0]); + return true; + } + + /** + * Returns whether the zoom has occurred + * @param {DateTime} date + */ + zoomIn(date) { + const index = this.allowedPrecisionLevels.indexOf(this.state.precision) - 1; + if (index in this.allowedPrecisionLevels) { + this.state.focusDate = this.clamp(date); + this.state.precision = this.allowedPrecisionLevels[index]; + return true; + } + return false; + } + + /** + * Returns whether the zoom has occurred + */ + zoomOut() { + const index = this.allowedPrecisionLevels.indexOf(this.state.precision) + 1; + if (index in this.allowedPrecisionLevels) { + this.state.precision = this.allowedPrecisionLevels[index]; + return true; + } + return false; + } + + /** + * Happens when a date item is selected: + * - first tries to zoom in on the item + * - if could not zoom in: date is considered as final value and triggers a hard select + * @param {DateItem} dateItem + */ + zoomOrSelect(dateItem) { + if (dateItem.isInvalid) { + // Invalid item + return; + } + if (this.zoomIn(dateItem.range[0])) { + // Zoom was successful + return; + } + const [value] = dateItem.range; + const valueIndex = this.props.focusedDateIndex; + const isValid = this.validateAndSelect(value, valueIndex); + this.shouldAdjustFocusDate = isValid && !this.isRange; + } + + //------------------------------------------------------------------------- + // Handlers + //------------------------------------------------------------------------- + + /** + * @param {KeyboardEvent} ev + */ + onWindowKeyDown(ev) { + const preventAnd = () => ev.preventDefault(); + + switch (ev.key) { + case "n": { + return preventAnd(this.next(ev)); + } + case "p": { + return preventAnd(this.previous(ev)); + } + } + + if (this.state.precision === "days" && ev.ctrlKey) { + switch (ev.key) { + case "ArrowDown": { + return preventAnd(this.navigate({ week: +1 })); + } + case "ArrowLeft": { + return preventAnd(this.navigate({ day: -1 })); + } + case "ArrowRight": { + return preventAnd(this.navigate({ day: +1 })); + } + case "ArrowUp": { + return preventAnd(this.navigate({ week: -1 })); + } + } + } + } +} diff --git a/addons/web/static/src/core/datetime/datetime_picker.scss b/addons/web/static/src/core/datetime/datetime_picker.scss new file mode 100644 index 00000000000..392369ffd8d --- /dev/null +++ b/addons/web/static/src/core/datetime/datetime_picker.scss @@ -0,0 +1,159 @@ +.o_datetime_picker { + --DateTimePicker__Cell-size-md: 3rem; + --DateTimePicker__Cell-size-lg: 5rem; + --DateTimePicker__Template-rows: 3; + --DateTimePicker__Template-columns: 4; + --DateTimePicker__Day-template-rows: 6; + --DateTimePicker__Today-highlight-color: white; + + // Header + + .o_datetime_picker_header .o_header_part { + text-transform: none; + } + + // Day + + .o_active { + --DateTimePicker__Cell-border-radius-left: 0; + --DateTimePicker__Cell-border-radius-right: 0; + + // border-radius: var(--DateTimePicker__Cell-border-radius-left) + // var(--DateTimePicker__Cell-border-radius-right) + // var(--DateTimePicker__Cell-border-radius-right) + // var(--DateTimePicker__Cell-border-radius-left); + + &.o_active_start, + &.o_active_end { + color: var(--DateTimePicker__Today-highlight-color); + } + + &:not(.o_active_start):not(.o_active_end) { + --bg-opacity: 0.4; + } + } + + .o_active_start { + &.o_cell_md { + --DateTimePicker__Cell-border-radius-left: 100vmax; + } + &.o_cell_lg { + --DateTimePicker__Cell-border-radius-left: 0.5rem; + } + } + + .o_active_end { + &.o_cell_md { + --DateTimePicker__Cell-border-radius-right: 100vmax; + } + &.o_cell_lg { + --DateTimePicker__Cell-border-radius-right: 0.5rem; + } + } + + .o_preview_active { + border-width: 2px 0 2px 0; + + &.o_preview_start { + border-width: 2px 0 2px 2px; + } + + &.o_preview_end { + border-width: 2px 2px 2px 0; + } + } + + .o_today:not(.o_out_of_range) { + --DateTimePicker__Today-border-color: var(--primary); + --DateTimePicker__Today-border-offset: 2px; + --DateTimePicker__Today-border-width: 5px; + + color: var(--DateTimePicker__Today-border-color); + position: relative; + box-shadow: inset 0 0 0 var(--DateTimePicker__Today-border-offset) + var(--DateTimePicker__Today-border-color); + font-weight: bold; + + &.o_active { + --DateTimePicker__Today-border-color: white; + } + + &:before { + content: ""; + right: var(--DateTimePicker__Today-border-offset); + bottom: var(--DateTimePicker__Today-border-offset); + border: solid transparent; + border-width: 0 0 var(--DateTimePicker__Today-border-width) + var(--DateTimePicker__Today-border-width); + border-bottom-color: var(--DateTimePicker__Today-border-color); + position: absolute; + } + } + + .o_out_of_range { + color: var(--gray-400); + --bg-opacity: 0.5; + } + + .o_time_picker_select { + background-image: none; + + &:focus, + &:hover { + border-color: var(--primary); + } + } + + // Grids + + .o_date_picker { + grid-template-rows: repeat(var(--DateTimePicker__Day-template-rows), 1fr); + grid-template-columns: repeat(var(--DateTimePicker__Day-template-columns), 1fr); + } + + .o_date_item_picker { + grid-template-rows: repeat(var(--DateTimePicker__Template-rows), 1fr); + grid-template-columns: repeat(var(--DateTimePicker__Template-columns), 1fr); + } + + // Utilities + + .o_datetime_button { + &:hover { + background-color: var(--gray-100); + border-color: var(--gray-200); + } + } + + .o_center { + display: grid; + place-items: center; + } + + .o_cell_md { + padding: 0.4rem; + width: var(--DateTimePicker__Cell-size-md); + height: var(--DateTimePicker__Cell-size-md); + } + + .o_cell_lg { + width: var(--DateTimePicker__Cell-size-lg); + height: var(--DateTimePicker__Cell-size-lg); + } + + .o_text_sm { + font-size: 0.875rem; + } + + .o_bg_inherit { + background-color: inherit; + } + + .o_text_inherit { + color: inherit; + } + + .o_hover_primary:hover { + color: var(--primary); + } +} diff --git a/addons/web/static/src/core/datetime/datetime_picker.xml b/addons/web/static/src/core/datetime/datetime_picker.xml new file mode 100644 index 00000000000..305f2a47441 --- /dev/null +++ b/addons/web/static/src/core/datetime/datetime_picker.xml @@ -0,0 +1,152 @@ + + + +
+ +
+ +
+
+
+ + + +
+ + + +
+
+
+
+ + +
+ + + + + : + + + + + + + + + + +
+
+
+
+ +
+
+
+ + +
+ + +
+
+ + + + + + + +
+ + + + + + + +
+
+ diff --git a/addons/web/static/src/core/datetime/datetime_picker_popover.js b/addons/web/static/src/core/datetime/datetime_picker_popover.js new file mode 100644 index 00000000000..a343596ddc9 --- /dev/null +++ b/addons/web/static/src/core/datetime/datetime_picker_popover.js @@ -0,0 +1,33 @@ +/** @odoo-module **/ + +import { Component } from "@odoo/owl"; +import { useHotkey } from "../hotkeys/hotkey_hook"; +import { DateTimePicker } from "./datetime_picker"; + +/** + * @typedef {import("./datetime_picker").DateTimePickerProps} DateTimePickerProps + * + * @typedef DateTimePickerPopoverProps + * @property {() => void} close + * @property {DateTimePickerProps} pickerProps + */ + +/** @extends {Component} */ +export class DateTimePickerPopover extends Component { + static components = { DateTimePicker }; + + static props = { + close: Function, // Given by the Popover service + pickerProps: { type: Object, shape: DateTimePicker.props }, + }; + + static template = "web.DateTimePickerPopover"; + + //------------------------------------------------------------------------- + // Lifecycle + //------------------------------------------------------------------------- + + setup() { + useHotkey("enter", () => this.props.close()); + } +} diff --git a/addons/web/static/src/core/datetime/datetime_picker_popover.xml b/addons/web/static/src/core/datetime/datetime_picker_popover.xml new file mode 100644 index 00000000000..42fae6ec48e --- /dev/null +++ b/addons/web/static/src/core/datetime/datetime_picker_popover.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/addons/web/static/src/core/domain_selector/domain_selector_fields.js b/addons/web/static/src/core/domain_selector/domain_selector_fields.js index 2fe11c26dbc..98a6333b2a2 100644 --- a/addons/web/static/src/core/domain_selector/domain_selector_fields.js +++ b/addons/web/static/src/core/domain_selector/domain_selector_fields.js @@ -1,7 +1,6 @@ /** @odoo-module **/ import { Component, useRef } from "@odoo/owl"; -import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker"; import { selectOperators } from "@web/core/domain_selector/domain_selector_operators"; import { deserializeDate, @@ -13,6 +12,9 @@ import { _lt } from "@web/core/l10n/translation"; import { evaluateExpr, formatAST } from "@web/core/py_js/py"; import { toPyValue } from "@web/core/py_js/py_utils"; import { registry } from "@web/core/registry"; +import { DateTimeInput } from "../datetime/datetime_input"; + +const { DateTime } = luxon; // ============================================================================ @@ -78,6 +80,20 @@ function makeEditor(component, props) { }; } +/** + * @param {"date" | "datetime"} type + * @param {Parameters} args + */ +const genericSerializeDate = (type, ...args) => + type === "date" ? serializeDate(...args) : serializeDateTime(...args); + +/** + * @param {"date" | "datetime"} type + * @param {Parameters} args + */ +const genericDeserializeDate = (type, ...args) => + type === "date" ? deserializeDate(...args) : deserializeDateTime(...args); + const DEFAULT = { operators: [ "equal", @@ -122,33 +138,6 @@ const BOOLEAN = { // ---------------------------------------------------------------------------- -const DATE = { - operators: [ - "equal", - "not_equal", - "greater_than", - "greater_equal", - "less_than", - "less_equal", - "set", - "not_set", - ], - editors: { - default: makeEditor(DatePicker, ({ value, update }) => ({ - date: deserializeDate(value), - onDateTimeChanged: (value) => { - if (!value.isValid) { - return; - } - return update(value ? serializeDate(value) : luxon.DateTime.local()); - }, - })), - }, - defaultValue: () => serializeDate(luxon.DateTime.local()), -}; - -// ---------------------------------------------------------------------------- - const DATETIME = { operators: [ "equal", @@ -161,17 +150,14 @@ const DATETIME = { "not_set", ], editors: { - default: makeEditor(DateTimePicker, ({ value, update }) => ({ - date: deserializeDateTime(value), - onDateTimeChanged: (value) => { - if (!value.isValid) { - return; - } - return update(value ? serializeDateTime(value) : luxon.DateTime.local()); - }, + default: makeEditor(DateTimeInput, ({ field, value, update }) => ({ + value: genericDeserializeDate(field.type, value), + type: field.type, + onApply: (value) => + update(value ? genericSerializeDate(field.type, value) : DateTime.local()), })), }, - defaultValue: () => serializeDateTime(luxon.DateTime.local()), + defaultValue: ({ type }) => genericSerializeDate(type, DateTime.local()), }; // ---------------------------------------------------------------------------- @@ -269,7 +255,7 @@ const PROPERTIES_RELATIONAL = { export const FIELD_DESCRIPTIONS = { boolean: BOOLEAN, char: TEXT, - date: DATE, + date: DATETIME, datetime: DATETIME, float: NUMBER, html: TEXT, diff --git a/addons/web/static/src/core/dropdown/dropdown.js b/addons/web/static/src/core/dropdown/dropdown.js index 55f5036ce4f..d607ef74cc4 100644 --- a/addons/web/static/src/core/dropdown/dropdown.js +++ b/addons/web/static/src/core/dropdown/dropdown.js @@ -311,7 +311,7 @@ export class Dropdown extends Component { return; } - if (ev.target.closest(".bootstrap-datetimepicker-widget")) { + if (ev.target.closest(".o_datetime_picker")) { return; } diff --git a/addons/web/static/src/views/fields/date/date_field.js b/addons/web/static/src/views/fields/date/date_field.js deleted file mode 100644 index e34109da8db..00000000000 --- a/addons/web/static/src/views/fields/date/date_field.js +++ /dev/null @@ -1,79 +0,0 @@ -/** @odoo-module **/ - -import { DatePicker } from "@web/core/datepicker/datepicker"; -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"; - -import { Component } from "@odoo/owl"; - -export class DateField extends Component { - static template = "web.DateField"; - static components = { - DatePicker, - }; - static props = { - ...standardFieldProps, - pickerOptions: { type: Object, optional: true }, - placeholder: { type: String, optional: true }, - }; - static defaultProps = { - pickerOptions: {}, - }; - - setup() { - /** - * The last value that has been commited to the model. - * Not changed in case of invalid field value. - */ - this.lastSetValue = null; - } - - get isDateTime() { - return this.props.record.fields[this.props.name].type === "datetime"; - } - get date() { - return ( - this.props.record.data[this.props.name] && - this.props.record.data[this.props.name].startOf("day") - ); - } - - get formattedValue() { - return this.isDateTime - ? formatDateTime(this.props.record.data[this.props.name], { - format: localization.dateFormat, - }) - : formatDate(this.props.record.data[this.props.name]); - } - - onDateTimeChanged(date) { - if (!areDateEquals(this.date || "", date)) { - this.props.record.update({ [this.props.name]: date }); - } - } - onDatePickerInput(ev) { - this.props.record.model.bus.trigger( - "FIELD_IS_DIRTY", - ev.target.value !== this.lastSetValue - ); - } - onUpdateInput(date) { - this.props.record.model.bus.trigger("FIELD_IS_DIRTY", false); - this.lastSetValue = date; - } -} - -export const dateField = { - component: DateField, - displayName: _lt("Date"), - supportedTypes: ["date", "datetime"], - extractProps: ({ attrs, options }) => ({ - pickerOptions: options.datepicker, - placeholder: attrs.placeholder, - }), -}; - -registry.category("fields").add("date", dateField); diff --git a/addons/web/static/src/views/fields/date/date_field.xml b/addons/web/static/src/views/fields/date/date_field.xml deleted file mode 100644 index 679adfcbbfb..00000000000 --- a/addons/web/static/src/views/fields/date/date_field.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - diff --git a/addons/web/static/src/views/fields/daterange/daterange_field.js b/addons/web/static/src/views/fields/daterange/daterange_field.js deleted file mode 100644 index 222e381e36c..00000000000 --- a/addons/web/static/src/views/fields/daterange/daterange_field.js +++ /dev/null @@ -1,193 +0,0 @@ -/** @odoo-module **/ - -import { _lt } from "@web/core/l10n/translation"; -import { registry } from "@web/core/registry"; -import { loadJS } from "@web/core/assets"; -import { luxonToMoment, momentToLuxon } from "@web/core/l10n/dates"; -import { useService } from "@web/core/utils/hooks"; -import { standardFieldProps } from "../standard_field_props"; - -import { Component, onWillStart, useExternalListener, useRef, useEffect } from "@odoo/owl"; -const formatters = registry.category("formatters"); -const parsers = registry.category("parsers"); - -export class DateRangeField extends Component { - static template = "web.DateRangeField"; - static props = { - ...standardFieldProps, - relatedEndDateField: { type: String, optional: true }, - relatedStartDateField: { type: String, optional: true }, - formatType: { type: String, optional: true }, - placeholder: { type: String, optional: true }, - }; - - setup() { - this.notification = useService("notification"); - this.root = useRef("root"); - this.isPickerShown = false; - this.pickerContainer; - - useExternalListener(window, "scroll", this.onWindowScroll, { capture: true }); - onWillStart(() => loadJS("/web/static/lib/daterangepicker/daterangepicker.js")); - useEffect( - (el) => { - if (el) { - window.$(el).daterangepicker({ - timePicker: this.isDateTime, - timePicker24Hour: true, - timePickerIncrement: 5, - autoUpdateInput: false, - locale: { - applyLabel: this.env._t("Apply"), - cancelLabel: this.env._t("Cancel"), - }, - startDate: this.startDate ? luxonToMoment(this.startDate) : window.moment(), - endDate: this.endDate ? luxonToMoment(this.endDate) : window.moment(), - drops: "auto", - }); - this.pickerContainer = window.$(el).data("daterangepicker").container[0]; - - window.$(el).on("apply.daterangepicker", this.onPickerApply.bind(this)); - window.$(el).on("show.daterangepicker", this.onPickerShow.bind(this)); - window.$(el).on("hide.daterangepicker", this.onPickerHide.bind(this)); - - this.pickerContainer.dataset.name = this.props.name; - } - - return () => { - if (el) { - this.pickerContainer.remove(); - } - }; - }, - () => [this.root.el, this.props.record.data[this.props.name]] - ); - } - - get formatType() { - return this.props.formatType || this.props.record.fields[this.props.name].type; - } - get isDateTime() { - return this.formatType === "datetime"; - } - get formattedValue() { - return this.formatValue(this.formatType, this.props.record.data[this.props.name]); - } - get formattedEndDate() { - return this.formatValue(this.formatType, this.endDate); - } - get formattedStartDate() { - return this.formatValue(this.formatType, this.startDate); - } - get startDate() { - return this.props.record.data[this.props.relatedStartDateField || this.props.name]; - } - get endDate() { - return this.props.record.data[this.props.relatedEndDateField || this.props.name]; - } - get relatedDateRangeField() { - return this.props.relatedStartDateField - ? this.props.relatedStartDateField - : this.props.relatedEndDateField; - } - - formatValue(format, value) { - const formatter = formatters.get(format); - let formattedValue; - try { - formattedValue = formatter(value); - } catch { - this.props.record.setInvalidField(this.props.name); - } - return formattedValue; - } - - updateRange(start, end) { - return this.props.record.update({ - [this.props.relatedStartDateField || this.props.name]: start, - [this.props.relatedEndDateField || this.props.name]: end, - }); - } - - onChangeInput(ev) { - const parse = parsers.get(this.formatType); - let value; - try { - value = parse(ev.target.value); - } catch { - this.props.record.setInvalidField(this.props.name); - return; - } - this.props.record.update({ [this.props.name]: value }); - } - - onWindowScroll(ev) { - const target = ev.target; - if ( - this.isPickerShown && - !this.env.isSmall && - (target === window || !this.pickerContainer.contains(target)) - ) { - window.$(this.root.el).data("daterangepicker").hide(); - } - } - - async onPickerApply(ev, picker) { - const start = this.isDateTime ? picker.startDate : picker.startDate.startOf("day"); - const end = this.isDateTime ? picker.endDate : picker.endDate.startOf("day"); - const dates = [start, end].map(momentToLuxon); - await this.updateRange(dates[0], dates[1]); - const input = document.querySelector( - `.o_field_daterange[name='${this.relatedDateRangeField}'] input` - ); - if (!input) { - // Don't attempt to update the related daterange field if not present in the DOM - return; - } - const target = window.$(input).data("daterangepicker"); - target.setStartDate(picker.startDate); - target.setEndDate(picker.endDate); - } - onPickerShow() { - this.isPickerShown = true; - } - onPickerHide() { - this.isPickerShown = false; - } -} - -export const dateRangeField = { - component: DateRangeField, - supportedOptions: [ - { - label: _lt("Format type"), - name: "format_type", - type: "selection", - choices: [ - { value: "date", label: _lt("Date") }, - { value: "datetime", label: _lt("Datetime") }, - ], - }, - { - label: _lt("Related Start Date"), - name: "related_start_date", - type: "field", - availableTypes: ["date", "datetime"], - }, - { - label: _lt("Related End Date"), - name: "related_end_date", - type: "field", - availableTypes: ["date", "datetime"], - }, - ], - supportedTypes: ["date", "datetime"], - extractProps: ({ attrs, options }) => ({ - relatedEndDateField: options.related_end_date, - relatedStartDateField: options.related_start_date, - formatType: options.format_type, - placeholder: attrs.placeholder, - }), -}; - -registry.category("fields").add("daterange", dateRangeField); diff --git a/addons/web/static/src/views/fields/daterange/daterange_field.xml b/addons/web/static/src/views/fields/daterange/daterange_field.xml deleted file mode 100644 index 0380d637b15..00000000000 --- a/addons/web/static/src/views/fields/daterange/daterange_field.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/addons/web/static/src/views/fields/datetime/datetime_field.js b/addons/web/static/src/views/fields/datetime/datetime_field.js index 1deac9a0a42..da33de41b58 100644 --- a/addons/web/static/src/views/fields/datetime/datetime_field.js +++ b/addons/web/static/src/views/fields/datetime/datetime_field.js @@ -1,64 +1,296 @@ /** @odoo-module **/ -import { DateTimePicker } from "@web/core/datepicker/datepicker"; -import { areDateEquals, formatDateTime } from "@web/core/l10n/dates"; +import { Component, onWillStart, onWillUpdateProps, useRef, useState } from "@odoo/owl"; +import { useDateTimePicker } from "@web/core/datetime/datetime_hook"; +import { + areDatesEqual, + deserializeDate, + deserializeDateTime, + formatDate, + formatDateTime, + today, +} from "@web/core/l10n/dates"; import { _lt } from "@web/core/l10n/translation"; import { registry } from "@web/core/registry"; +import { ensureArray } from "@web/core/utils/arrays"; +import { archParseBoolean } from "@web/views/utils"; import { standardFieldProps } from "../standard_field_props"; -import { Component } from "@odoo/owl"; +/** + * @typedef {luxon.DateTime} DateTime + * + * @typedef {import("../standard_field_props").StandardFieldProps & { + * endDateField?: string; + * maxDate?: string; + * minDate?: string; + * placeholder?: string; + * required?: boolean; + * rounding?: number; + * warnFuture?: boolean; + * }} DateTimeFieldProps + * + * @typedef {import("@web/core/datetime/datetime_picker").DateTimePickerProps} DateTimePickerProps + */ +/** @extends {Component} */ export class DateTimeField extends Component { - static template = "web.DateTimeField"; - static components = { - DateTimePicker, - }; static props = { ...standardFieldProps, - pickerOptions: { type: Object, optional: true }, + endDateField: { type: String, optional: true }, + maxDate: { type: String, optional: true }, + minDate: { type: String, optional: true }, placeholder: { type: String, optional: true }, + required: { type: Boolean, optional: true }, + rounding: { type: Number, optional: true }, + warnFuture: { type: Boolean, optional: true }, }; - static defaultProps = { - pickerOptions: {}, - }; + + static template = "web.DateTimeField"; + + //------------------------------------------------------------------------- + // Getters + //------------------------------------------------------------------------- + + get hasEmptyField() { + return this.values.length < 2 || !this.values.every(Boolean); + } + + /** @type {string[]} */ + get refValues() { + return this.inputRefs.map((ref) => ref.el?.value); + } + + get showEndDateInput() { + return this.props.endDateField && this.values.filter(Boolean).length; + } + + get values() { + return ensureArray(this.state.value); + } + + //------------------------------------------------------------------------- + // Lifecycle + //------------------------------------------------------------------------- setup() { - /** - * The last value that has been commited to the model. - * Not changed in case of invalid field value. - */ - this.lastSetValue = null; + const { endDateField, name } = this.props; // should not change + + this.rootRef = useRef("root"); + this.inputRefs = [useRef("start-date"), useRef("end-date")]; + + const state = useDateTimePicker({ + target: "root", + pickerProps: (props) => this.getPickerProps(props), + onChange: (value) => { + if (Array.isArray(value)) { + if (value.every(Boolean)) { + this.emptyField = false; + } else { + this.emptyField = value[0] ? endDateField : name; + this.state.value = value.find(Boolean); + } + } + this.triggerIsDirty(this.props); + }, + onApply: (value) => { + const toUpdate = {}; + if (Array.isArray(value)) { + // Value is already a range + [toUpdate[name], toUpdate[endDateField]] = value; + } else { + toUpdate[this.emptyField === name ? endDateField : name] = value; + if (endDateField && this.emptyField) { + toUpdate[this.emptyField] = false; + } + } + // Remove values that did not change + for (const fieldName in toUpdate) { + if (areDatesEqual(toUpdate[fieldName], this.props.record.data[fieldName])) { + delete toUpdate[fieldName]; + } + } + this.props.record.update(toUpdate); + }, + }); + this.state = useState(state); + + onWillStart(() => this.triggerIsDirty(this.props)); + onWillUpdateProps((nextProps) => this.triggerIsDirty(nextProps)); } - get formattedValue() { - return formatDateTime(this.props.record.data[this.props.name]); + //------------------------------------------------------------------------- + // Methods + //------------------------------------------------------------------------- + + async addDate() { + const [value] = this.values; + this.state.focusedDateIndex = this.emptyField === this.props.name ? 0 : 1; + this.state.value = [value, value]; } - onDateTimeChanged(date) { - if (!areDateEquals(this.props.record.data[this.props.name] || "", date)) { - this.props.record.update({ [this.props.name]: date }); + formatDisplayValue(value) { + if (typeof value === "string") { + return value; } + return value ? (this.type === "date" ? formatDate(value) : formatDateTime(value)) : ""; } - onDatePickerInput(ev) { + + /** + * @param {DateTimeFieldProps} props + */ + getPickerProps(props) { + const { endDateField, record, name, maxDate, minDate, rounding } = props; + const value = this.getValueFromProps(props); + + // Compute own props + this.type = record.fields[name].type; + if (endDateField) { + this.emptyField = + !Array.isArray(value) && (record.data[endDateField] ? name : endDateField); + } + + // Compute picker props + /** @type {DateTimePickerProps} */ + const pickerProps = { value, type: this.type }; + if (maxDate) { + pickerProps.maxDate = maxDate === "today" ? maxDate : this.parseLimitDate(maxDate); + } + if (minDate) { + pickerProps.minDate = minDate === "today" ? minDate : this.parseLimitDate(minDate); + } + if (!isNaN(rounding)) { + pickerProps.rounding = rounding; + } + return pickerProps; + } + + /** + * @param {DateTimeFieldProps} props + * @returns {DateTimePickerProps["value"]} + */ + getValueFromProps({ endDateField, record, required, name }) { + const value = record.data[name]; + if (endDateField) { + const endValue = record.data[endDateField]; + if (required || (value && endValue)) { + return [value, endValue]; + } else if (!value) { + return endValue; + } + } + return value; + } + + /** + * @param {number} index + */ + isDateInTheFuture(index) { + return this.values[index] > today(); + } + + /** + * @param {string} value + */ + parseLimitDate(value) { + return this.type === "date" ? deserializeDate(value) : deserializeDateTime(value); + } + + /** + * The given props are used to compute the current value and compare it to + * the state handled by the datetime hook. + * + * @param {DateTimeFieldProps} props + */ + triggerIsDirty(props) { this.props.record.model.bus.trigger( "FIELD_IS_DIRTY", - ev.target.value !== this.lastSetValue + !areDatesEqual(this.getValueFromProps(props), this.state.value) ); } - onUpdateInput(date) { - this.props.record.model.bus.trigger("FIELD_IS_DIRTY", false); - this.lastSetValue = date; + + //------------------------------------------------------------------------- + // Handlers + //------------------------------------------------------------------------- + + onInput() { + this.props.record.model.bus.trigger("FIELD_IS_DIRTY", true); } } -export const dateTimeField = { +const END_DATE_FIELD_OPTION = "end_date_field"; + +export const dateField = { component: DateTimeField, - displayName: _lt("Date & Time"), - supportedTypes: ["datetime"], - extractProps: ({ attrs, options }) => ({ - pickerOptions: options.datepicker, + displayName: _lt("Date"), + supportedOptions: [ + { + label: _lt("Earliest accepted date"), + name: "min_date", + type: "string", + help: _lt(`ISO-formatted date (e.g. "2018-12-31") or "today".`), + }, + { + label: _lt("Latest accepted date"), + name: "max_date", + type: "string", + help: _lt(`ISO-formatted date (e.g. "2018-12-31") or "today".`), + }, + { + label: _lt("Warning for future dates"), + name: "warn_future", + type: "boolean", + help: _lt(`Displays a warning icon if the input dates are in the future.`), + }, + ], + supportedTypes: ["date"], + extractProps: ({ attrs, modifiers, options }) => ({ + endDateField: options[END_DATE_FIELD_OPTION], + maxDate: options.max_date, + minDate: options.min_date, placeholder: attrs.placeholder, + required: Boolean(modifiers.required), + rounding: parseInt(options.rounding), + warnFuture: archParseBoolean(options.warn_future), }), + fieldDependencies: ({ type, modifiers, options }) => + options[END_DATE_FIELD_OPTION] && [ + { name: options[END_DATE_FIELD_OPTION], type, modifiers }, + ], }; -registry.category("fields").add("datetime", dateTimeField); +export const dateTimeField = { + ...dateField, + displayName: _lt("Date & Time"), + supportedOptions: [ + ...dateField.supportedOptions, + { + label: _lt("Rounding"), + name: "rounding", + type: "number", + default: 5, + help: _lt(`Increment used in the minutes selection dropdown.`), + }, + ], + supportedTypes: ["datetime"], +}; + +export const dateRangeField = { + ...dateTimeField, + displayName: _lt("Date Range"), + supportedOptions: [ + ...dateTimeField.supportedOptions, + { + label: _lt("End date field"), + name: "end_date_field", + type: "field", + availableTypes: ["date", "datetime"], + }, + ], + supportedTypes: ["date", "datetime"], +}; + +registry + .category("fields") + .add("date", dateField) + .add("daterange", dateRangeField) + .add("datetime", dateTimeField); diff --git a/addons/web/static/src/views/fields/datetime/datetime_field.scss b/addons/web/static/src/views/fields/datetime/datetime_field.scss new file mode 100644 index 00000000000..502ff3af9f8 --- /dev/null +++ b/addons/web/static/src/views/fields/datetime/datetime_field.scss @@ -0,0 +1,14 @@ +.o_field_date, +.o_field_datetime, +.o_field_daterange { + .o_add_date { + visibility: hidden; + } + + &:focus-within, + &:hover { + .o_add_date { + visibility: visible; + } + } +} diff --git a/addons/web/static/src/views/fields/datetime/datetime_field.xml b/addons/web/static/src/views/fields/datetime/datetime_field.xml index b9f91c63953..3b6648aa45f 100644 --- a/addons/web/static/src/views/fields/datetime/datetime_field.xml +++ b/addons/web/static/src/views/fields/datetime/datetime_field.xml @@ -1,21 +1,63 @@ - - - - - - - +
+ + +
+ + + + + + + + + + + + + + + +
+ + + + + + + +
- diff --git a/addons/web/static/src/views/fields/properties/properties_field.js b/addons/web/static/src/views/fields/properties/properties_field.js index 440d063854c..b48446fe9b1 100644 --- a/addons/web/static/src/views/fields/properties/properties_field.js +++ b/addons/web/static/src/views/fields/properties/properties_field.js @@ -143,7 +143,7 @@ export class PropertiesField extends Component { * @returns {boolean} */ checkPopoverClose(target) { - if (target.closest(".bootstrap-datetimepicker-widget")) { + if (target.closest(".o_datetime_picker")) { // selected a datetime, do not close the definition popover return false; } 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 1a5cd0446f5..05c90caf8ed 100644 --- a/addons/web/static/src/views/fields/properties/property_value.js +++ b/addons/web/static/src/views/fields/properties/property_value.js @@ -1,19 +1,12 @@ /** @odoo-module **/ -import { _lt } from "@web/core/l10n/translation"; +import { Component } from "@odoo/owl"; +import { AutoComplete } from "@web/core/autocomplete/autocomplete"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { DateTimeInput } from "@web/core/datetime/datetime_input"; +import { Domain } from "@web/core/domain"; import { Dropdown } from "@web/core/dropdown/dropdown"; import { DropdownItem } from "@web/core/dropdown/dropdown_item"; -import { CheckBox } from "@web/core/checkbox/checkbox"; -import { DateTimePicker, DatePicker } from "@web/core/datepicker/datepicker"; -import { Domain } from "@web/core/domain"; -import { Many2XAutocomplete, useOpenMany2XRecord } from "@web/views/fields/relational_utils"; -import { useService } from "@web/core/utils/hooks"; -import { TagsList } from "@web/core/tags_list/tags_list"; -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 { parseFloat, parseInteger } from "@web/views/fields/parsers"; import { deserializeDate, deserializeDateTime, @@ -22,8 +15,14 @@ import { serializeDate, serializeDateTime, } from "@web/core/l10n/dates"; - -import { Component } from "@odoo/owl"; +import { _lt } from "@web/core/l10n/translation"; +import { TagsList } from "@web/core/tags_list/tags_list"; +import { useService } from "@web/core/utils/hooks"; +import { formatFloat, formatInteger, formatMany2one } from "@web/views/fields/formatters"; +import { m2oTupleFromData } from "@web/views/fields/many2one/many2one_field"; +import { parseFloat, parseInteger } from "@web/views/fields/parsers"; +import { Many2XAutocomplete, useOpenMany2XRecord } from "@web/views/fields/relational_utils"; +import { PropertyTags } from "./property_tags"; /** * Represent one property value. @@ -341,8 +340,7 @@ PropertyValue.components = { Dropdown, DropdownItem, CheckBox, - DateTimePicker, - DatePicker, + DateTimeInput, Many2XAutocomplete, TagsList, AutoComplete, diff --git a/addons/web/static/src/views/fields/properties/property_value.xml b/addons/web/static/src/views/fields/properties/property_value.xml index 6c847797438..65cbc2e677b 100644 --- a/addons/web/static/src/views/fields/properties/property_value.xml +++ b/addons/web/static/src/views/fields/properties/property_value.xml @@ -13,18 +13,12 @@ onChange.bind="onValueChange" />
- - - - - + 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 ccc9dae71a2..ba7883cb451 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,53 +1,38 @@ /** @odoo-module **/ -import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker"; +import { Component } from "@odoo/owl"; 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 { registry } from "@web/core/registry"; +import { DateTimeField } from "../datetime/datetime_field"; import { standardFieldProps } from "../standard_field_props"; -import { Component } from "@odoo/owl"; +const { DateTime } = luxon; export class RemainingDaysField extends Component { + static components = { DateTimeField }; + + static props = standardFieldProps; + static template = "web.RemainingDaysField"; - static props = { - ...standardFieldProps, - }; - - get hasTime() { - return this.props.record.fields[this.props.name].type === "datetime"; - } - - get pickerComponent() { - return this.hasTime ? DateTimePicker : DatePicker; - } get diffDays() { - if (!this.props.record.data[this.props.name]) { + const { record, name } = this.props; + const value = record.data[name]; + if (!value) { return null; } - const today = luxon.DateTime.local().startOf("day"); - return Math.floor( - this.props.record.data[this.props.name].startOf("day").diff(today, "days").days - ); + const today = DateTime.local().startOf("day"); + const diff = value.startOf("day").diff(today, "days"); + return Math.floor(diff.days); } get formattedValue() { - return this.hasTime - ? formatDateTime(this.props.record.data[this.props.name], { - format: localization.dateFormat, - }) - : formatDate(this.props.record.data[this.props.name]); - } - - onDateTimeChanged(datetime) { - if (datetime) { - this.props.record.update({ [this.props.name]: datetime }); - } else if (typeof datetime === "string") { - // when the date is cleared - this.props.record.update({ [this.props.name]: false }); - } + const { record, name } = this.props; + return record.fields[name].type === "datetime" + ? formatDateTime(record.data[name], { format: localization.dateFormat }) + : formatDate(record.data[name]); } } diff --git a/addons/web/static/src/views/fields/remaining_days/remaining_days_field.xml b/addons/web/static/src/views/fields/remaining_days/remaining_days_field.xml index cec5885218f..08af63bf7d3 100644 --- a/addons/web/static/src/views/fields/remaining_days/remaining_days_field.xml +++ b/addons/web/static/src/views/fields/remaining_days/remaining_days_field.xml @@ -1,6 +1,5 @@ - @@ -24,10 +23,7 @@
-
- -
+
- diff --git a/addons/web/static/src/views/kanban/kanban_record_quick_create.js b/addons/web/static/src/views/kanban/kanban_record_quick_create.js index 86600dcff23..c29da4bff9b 100644 --- a/addons/web/static/src/views/kanban/kanban_record_quick_create.js +++ b/addons/web/static/src/views/kanban/kanban_record_quick_create.js @@ -91,9 +91,9 @@ class KanbanQuickCreateController extends Component { return; } const target = this.mousedownTarget || ev.target; - // accounts for clicking on legacy daterangepicker and legacy autocomplete + // accounts for clicking on datetime picker and legacy autocomplete const gotClickedInside = - target.closest(".daterangepicker") || + target.closest(".o_datetime_picker") || target.closest(".ui-autocomplete") || this.rootRef.el.contains(target); if (!gotClickedInside) { diff --git a/addons/web/static/tests/core/datepicker_tests.js b/addons/web/static/tests/core/datepicker_tests.js deleted file mode 100644 index 4af80e8526d..00000000000 --- a/addons/web/static/tests/core/datepicker_tests.js +++ /dev/null @@ -1,657 +0,0 @@ -/** @odoo-module **/ - -import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker"; -import { registry } from "@web/core/registry"; -import { uiService } from "@web/core/ui/ui_service"; -import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; -import { registerCleanup } from "../helpers/cleanup"; -import { makeTestEnv } from "../helpers/mock_env"; -import { makeFakeLocalizationService } from "../helpers/mock_services"; -import { click, getFixture, mount, triggerEvent } from "../helpers/utils"; - -import { Component, useState, xml } from "@odoo/owl"; -const { DateTime } = luxon; - -const serviceRegistry = registry.category("services"); - -let target; - -/** - * @param {typeof DatePicker} Picker - * @param {Object} props - * @returns {Promise} - */ -async function mountPicker(Picker, props) { - serviceRegistry - .add( - "localization", - makeFakeLocalizationService({ - dateFormat: "dd/MM/yyyy", - dateTimeFormat: "dd/MM/yyyy HH:mm:ss", - }) - ) - .add("ui", uiService) - .add("hotkey", hotkeyService); - - class Parent extends Component { - setup() { - this.state = useState(props); - } - - onDateChange(date) { - if (props.onDateTimeChanged) { - props.onDateTimeChanged(date); - } - this.state.date = date; - } - } - Parent.template = xml/* xml */ ` - - `; - - const env = await makeTestEnv(); - await mount(Parent, target, { env, props: { Picker } }); -} - -function useFRLocale() { - if (!window.moment.locales().includes("fr")) { - // Mocks the FR locale if not loaded - const originalLocale = window.moment.locale(); - window.moment.defineLocale("fr", { - months: "janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split( - "_" - ), - monthsShort: "janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split( - "_" - ), - code: "fr", - monthsParseExact: true, - week: { dow: 1, doy: 4 }, - }); - // Moment automatically assigns newly defined locales. - window.moment.locale(originalLocale); - registerCleanup(() => window.moment.updateLocale("fr", null)); - } - return "fr"; -} - -var symbolMap = { - 1: "૧", - 2: "૨", - 3: "૩", - 4: "૪", - 5: "૫", - 6: "૬", - 7: "૭", - 8: "૮", - 9: "૯", - 0: "૦", -}; -var numberMap = { - "૧": "1", - "૨": "2", - "૩": "3", - "૪": "4", - "૫": "5", - "૬": "6", - "૭": "7", - "૮": "8", - "૯": "9", - "૦": "0", -}; - -function useGULocale() { - if (!window.moment.locales().includes("gu")) { - const originalLocale = window.moment.locale(); - window.moment.defineLocale("gu", { - months: "જાન્યુઆરી_ફેબ્રુઆરી_માર્ચ_એપ્રિલ_મે_જૂન_જુલાઈ_ઑગસ્ટ_સપ્ટેમ્બર_ઑક્ટ્બર_નવેમ્બર_ડિસેમ્બર".split( - "_" - ), - monthsShort: - "જાન્યુ._ફેબ્રુ._માર્ચ_એપ્રિ._મે_જૂન_જુલા._ઑગ._સપ્ટે._ઑક્ટ્._નવે._ડિસે.".split("_"), - monthsParseExact: true, - week: { - dow: 0, // Sunday is the first day of the week. - doy: 6, // The week that contains Jan 1st is the first week of the year. - }, - preparse: function (string) { - return string.replace(/[૧૨૩૪૫૬૭૮૯૦]/g, function (match) { - return numberMap[match]; - }); - }, - postformat: function (string) { - return string.replace(/\d/g, function (match) { - return symbolMap[match]; - }); - }, - }); - // Moment automatically assigns newly defined locales. - window.moment.locale(originalLocale); - registerCleanup(() => window.moment.updateLocale("gu", null)); - } - return "gu"; -} - -function useNOLocale() { - if (!window.moment.locales().includes("nb")) { - const originalLocale = window.moment.locale(); - window.moment.defineLocale("nb", { - months: "januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split( - "_" - ), - monthsShort: "jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.".split("_"), - monthsParseExact: true, - week: { - dow: 1, // Monday is the first day of the week. - doy: 4, // The week that contains Jan 4th is the first week of the year. - }, - }); - // Moment automatically assigns newly defined locales. - window.moment.locale(originalLocale); - registerCleanup(() => window.moment.updateLocale("nb", null)); - } - return "nb"; -} - -QUnit.module("Components", ({ beforeEach }) => { - beforeEach(() => { - target = getFixture(); - }); - - QUnit.module("DatePicker"); - - QUnit.test("basic rendering", async function (assert) { - assert.expect(8); - - await mountPicker(DatePicker, { - date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), - }); - - assert.containsOnce(target, "input.o_input.o_datepicker_input"); - assert.containsOnce(target, "span.o_datepicker_button"); - assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget"); - - const input = target.querySelector("input.o_input.o_datepicker_input"); - assert.strictEqual(input.value, "09/01/1997", "Value should be the one given"); - assert.strictEqual( - input.dataset.target, - `#${target.querySelector(".o_datepicker").id}`, - "DatePicker id should match its input target" - ); - - await click(input); - - assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker"); - assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget .timepicker"); - assert.strictEqual( - document.querySelector(".datepicker .day.active").dataset.day, - "01/09/1997", - "Datepicker should have set the correct day" - ); - }); - - QUnit.test("pick a date", async function (assert) { - assert.expect(5); - - await mountPicker(DatePicker, { - date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy"), - "08/02/1997", - "Event should transmit the correct date" - ); - }, - }); - const input = target.querySelector(".o_datepicker_input"); - - await click(input); - await click(document.querySelector(".datepicker th.next")); // next month - - assert.verifySteps([]); - - await click(document.querySelectorAll(".datepicker table td")[15]); // previous day - - assert.strictEqual(input.value, "08/02/1997"); - assert.verifySteps(["datetime-changed"]); - }); - - QUnit.test("pick a date with locale (locale given in props)", async function (assert) { - assert.expect(5); - - await mountPicker(DatePicker, { - date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), - format: "dd MMM, yyyy", - locale: useFRLocale(), - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy"), - "01/09/1997", - "Event should transmit the correct date" - ); - }, - }); - const input = target.querySelector(".o_datepicker_input"); - - assert.strictEqual(input.value, "09 janv., 1997"); - - await click(input); - await click(document.querySelector(".datepicker .picker-switch")); // month picker - await click(document.querySelectorAll(".datepicker .month")[8]); // september - await click(document.querySelector(".datepicker .day")); // first day - - assert.strictEqual(input.value, "01 sept., 1997"); - assert.verifySteps(["datetime-changed"]); - }); - - QUnit.test("pick a date with locale (locale from date props)", async function (assert) { - assert.expect(5); - - await mountPicker(DatePicker, { - date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { - zone: "utc", - locale: useFRLocale(), - }), - format: "dd MMM, yyyy", - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy"), - "01/09/1997", - "Event should transmit the correct date" - ); - }, - }); - const input = target.querySelector(".o_datepicker_input"); - - assert.strictEqual(input.value, "09 janv., 1997"); - - await click(input); - await click(document.querySelector(".datepicker .picker-switch")); // month picker - await click(document.querySelectorAll(".datepicker .month")[8]); // september - await click(document.querySelector(".datepicker .day")); // first day - - assert.strictEqual(input.value, "01 sept., 1997"); - assert.verifySteps(["datetime-changed"]); - }); - - QUnit.test("pick a date with locale (locale with different symbols)", async function (assert) { - assert.expect(6); - - await mountPicker(DatePicker, { - date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { - zone: "utc", - locale: useGULocale(), - }), - format: "dd MMM, yyyy", - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy"), - "01/09/1997", - "Event should transmit the correct date" - ); - }, - }); - const input = target.querySelector(".o_datepicker_input"); - - assert.strictEqual(input.value, "09 જાન્યુ, 1997"); - - await click(input); - assert.strictEqual(input.value, "૧૯૯૭/૦૧/૦૯"); - await click(document.querySelector(".datepicker .picker-switch")); // month picker - await click(document.querySelectorAll(".datepicker .month")[8]); // september - await click(document.querySelectorAll(".datepicker .day")[1]); // first day of september - - assert.strictEqual(input.value, "01 સપ્ટે, 1997"); - assert.verifySteps(["datetime-changed"]); - }); - - QUnit.test("enter a date value", async function (assert) { - assert.expect(5); - - await mountPicker(DatePicker, { - date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy"), - "08/02/1997", - "Event should transmit the correct date" - ); - }, - }); - const input = target.querySelector(".o_datepicker_input"); - - assert.verifySteps([]); - - input.value = "08/02/1997"; - await triggerEvent(target, ".o_datepicker_input", "change"); - - assert.verifySteps(["datetime-changed"]); - - await click(input); - - assert.strictEqual( - document.querySelector(".datepicker .day.active").dataset.day, - "02/08/1997", - "Datepicker should have set the correct day" - ); - }); - - QUnit.test("Date format is correctly set", async function (assert) { - assert.expect(2); - - await mountPicker(DatePicker, { - date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }), - format: "yyyy/MM/dd", - }); - const input = target.querySelector(".o_datepicker_input"); - - assert.strictEqual(input.value, "1997/01/09"); - - // Forces an update to assert that the registered format is the correct one - await click(input); - - assert.strictEqual(input.value, "1997/01/09"); - }); - - QUnit.module("DateTimePicker"); - - QUnit.test("basic rendering", async function (assert) { - assert.expect(11); - - await mountPicker(DateTimePicker, { - date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), - }); - - assert.containsOnce(target, "input.o_input.o_datepicker_input"); - assert.containsOnce(target, "span.o_datepicker_button"); - assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget"); - - const input = target.querySelector("input.o_input.o_datepicker_input"); - assert.strictEqual(input.value, "09/01/1997 12:30:01", "Value should be the one given"); - assert.strictEqual( - input.dataset.target, - `#${target.querySelector(".o_datepicker").id}`, - "DateTimePicker id should match its input target" - ); - - await click(input); - - assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker"); - assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .timepicker"); - assert.strictEqual( - document.querySelector(".datepicker .day.active").dataset.day, - "01/09/1997", - "Datepicker should have set the correct day" - ); - - assert.strictEqual( - document.querySelector(".timepicker .timepicker-hour").innerText.trim(), - "12", - "Datepicker should have set the correct hour" - ); - assert.strictEqual( - document.querySelector(".timepicker .timepicker-minute").innerText.trim(), - "30", - "Datepicker should have set the correct minute" - ); - assert.strictEqual( - document.querySelector(".timepicker .timepicker-second").innerText.trim(), - "01", - "Datepicker should have set the correct second" - ); - }); - - QUnit.test("pick a date and time", async function (assert) { - assert.expect(5); - - await mountPicker(DateTimePicker, { - date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy HH:mm:ss"), - "08/02/1997 15:45:05", - "Event should transmit the correct date" - ); - }, - }); - const input = target.querySelector("input.o_input.o_datepicker_input"); - - await click(input); - await click(document.querySelector(".datepicker th.next")); // February - await click(document.querySelectorAll(".datepicker table td")[15]); // 08 - await click(document.querySelector('a[title="Select Time"]')); - await click(document.querySelector(".timepicker .timepicker-hour")); - await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h - await click(document.querySelector(".timepicker .timepicker-minute")); - await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m - await click(document.querySelector(".timepicker .timepicker-second")); - - assert.verifySteps([]); - - await click(document.querySelectorAll(".timepicker .second")[1]); // 05s - - assert.strictEqual(input.value, "08/02/1997 15:45:05"); - assert.verifySteps(["datetime-changed"]); - }); - - QUnit.test("pick a date and time with locale", async function (assert) { - assert.expect(6); - - await mountPicker(DateTimePicker, { - date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), - format: "dd MMM, yyyy HH:mm:ss", - locale: useFRLocale(), - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy HH:mm:ss"), - "01/09/1997 15:45:05", - "Event should transmit the correct date" - ); - }, - }); - - const input = target.querySelector("input.o_input.o_datepicker_input"); - - assert.strictEqual(input.value, "09 janv., 1997 12:30:01"); - - await click(input); - - await click(document.querySelector(".datepicker .picker-switch")); // month picker - await click(document.querySelectorAll(".datepicker .month")[8]); // september - await click(document.querySelector(".datepicker .day")); // first day - - await click(document.querySelector('a[title="Select Time"]')); - await click(document.querySelector(".timepicker .timepicker-hour")); - await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h - await click(document.querySelector(".timepicker .timepicker-minute")); - await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m - await click(document.querySelector(".timepicker .timepicker-second")); - - assert.verifySteps([]); - - await click(document.querySelectorAll(".timepicker .second")[1]); // 05s - - assert.strictEqual(input.value, "01 sept., 1997 15:45:05"); - assert.verifySteps(["datetime-changed"]); - }); - - QUnit.test("pick a time with 12 hour format locale", async function (assert) { - assert.expect(6); - - await mountPicker(DateTimePicker, { - date: DateTime.fromFormat("09/01/1997 08:30:01", "dd/MM/yyyy hh:mm:ss"), - format: "dd/MM/yyyy hh:mm:ss", - locale: useFRLocale(), - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy HH:mm:ss"), - "09/01/1997 20:30:02", - "The new time should be in the afternoon" - ); - }, - }); - - const input = target.querySelector("input.o_input.o_datepicker_input"); - - assert.strictEqual(input.value, "09/01/1997 08:30:01"); - - await click(input); - - await click(document.querySelector('a[title="Select Time"]')); - await click(document.querySelector('a[title="Increment Second"]')); - await click(document.querySelector('button[title="Toggle Period"]')); - - assert.verifySteps([]); - - await click(document.querySelector('a[title="Close the picker"]')); - - assert.strictEqual(input.value, "09/01/1997 08:30:02"); - assert.verifySteps(["datetime-changed"]); - }); - - QUnit.test("enter a datetime value", async function (assert) { - assert.expect(9); - - await mountPicker(DateTimePicker, { - date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), - onDateTimeChanged: (date) => { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy HH:mm:ss"), - "08/02/1997 15:45:05", - "Event should transmit the correct date" - ); - }, - }); - const input = target.querySelector(".o_datepicker_input"); - - assert.verifySteps([]); - - input.value = "08/02/1997 15:45:05"; - await triggerEvent(target, ".o_datepicker_input", "change"); - - assert.verifySteps(["datetime-changed"]); - - await click(input); - - assert.strictEqual(input.value, "08/02/1997 15:45:05"); - assert.strictEqual( - document.querySelector(".datepicker .day.active").dataset.day, - "02/08/1997", - "Datepicker should have set the correct day" - ); - assert.strictEqual( - document.querySelector(".timepicker .timepicker-hour").innerText.trim(), - "15", - "Datepicker should have set the correct hour" - ); - assert.strictEqual( - document.querySelector(".timepicker .timepicker-minute").innerText.trim(), - "45", - "Datepicker should have set the correct minute" - ); - assert.strictEqual( - document.querySelector(".timepicker .timepicker-second").innerText.trim(), - "05", - "Datepicker should have set the correct second" - ); - }); - - QUnit.test("Date time format is correctly set", async function (assert) { - assert.expect(2); - - await mountPicker(DateTimePicker, { - date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), - format: "HH:mm:ss yyyy/MM/dd", - }); - const input = target.querySelector(".o_datepicker_input"); - - assert.strictEqual(input.value, "12:30:01 1997/01/09"); - - // Forces an update to assert that the registered format is the correct one - await click(input); - - assert.strictEqual(input.value, "12:30:01 1997/01/09"); - }); - - QUnit.test("Datepicker works with norwegian locale", async (assert) => { - assert.expect(6); - - await mountPicker(DatePicker, { - date: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), - format: "dd MMM, yyyy", - locale: useNOLocale(), - onDateTimeChanged(date) { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy"), - "01/04/1997", - "Event should transmit the correct date" - ); - }, - }); - - const input = target.querySelector(".o_datepicker_input"); - - assert.strictEqual(input.value, "09 apr., 1997"); - - await click(input); - - assert.strictEqual(input.value, "1997/04/09"); - - const days = [...document.querySelectorAll(".datepicker .day")]; - await click(days.find((d) => d.innerText.trim() === "1")); // first day of april - - assert.strictEqual(input.value, "01 apr., 1997"); - assert.verifySteps(["datetime-changed"]); - }); - - QUnit.test("Datepicker works with dots and commas in format", async (assert) => { - assert.expect(2); - - await mountPicker(DateTimePicker, { - date: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"), - format: "dd.MM,yyyy", - }); - const input = target.querySelector(".o_datepicker_input"); - - assert.strictEqual(input.value, "10.03,2023"); - - await click(input); - - assert.strictEqual(input.value, "10.03,2023"); - }); - - QUnit.test("start with no value", async function (assert) { - assert.expect(6); - - await mountPicker(DateTimePicker, { - onDateTimeChanged(date) { - assert.step("datetime-changed"); - assert.strictEqual( - date.toFormat("dd/MM/yyyy HH:mm:ss"), - "08/02/1997 15:45:05", - "Event should transmit the correct date" - ); - }, - }); - - const input = target.querySelector(".o_datepicker_input"); - assert.strictEqual(input.value, ""); - - assert.verifySteps([]); - input.value = "08/02/1997 15:45:05"; - await triggerEvent(target, ".o_datepicker_input", "change"); - - assert.verifySteps(["datetime-changed"]); - assert.strictEqual(input.value, "08/02/1997 15:45:05"); - }); -}); diff --git a/addons/web/static/tests/core/datetime/datetime_input_tests.js b/addons/web/static/tests/core/datetime/datetime_input_tests.js new file mode 100644 index 00000000000..72c045cef5e --- /dev/null +++ b/addons/web/static/tests/core/datetime/datetime_input_tests.js @@ -0,0 +1,484 @@ +/** @odoo-module **/ + +import { Component, xml } from "@odoo/owl"; +import { clearRegistryWithCleanup, makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services"; +import { + click, + editInput, + editSelect, + getFixture, + mount, + patchWithCleanup, + triggerEvent, +} from "@web/../tests/helpers/utils"; +import { DateTimeInput } from "@web/core/datetime/datetime_input"; +import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; +import { localization } from "@web/core/l10n/localization"; +import { popoverService } from "@web/core/popover/popover_service"; +import { registry } from "@web/core/registry"; +import { uiService } from "@web/core/ui/ui_service"; +import { + assertDateTimePicker, + getPickerCell, + getTimePickers, + zoomOut, +} from "./datetime_test_helpers"; + +const { DateTime } = luxon; + +/** + * @typedef {import("@web/core/datetime/datetime_input").DateTimeInputProps} DateTimeInputProps + */ + +/** + * @param {DateTimeInputProps} props + */ +const mountInput = async (props) => { + const env = await makeTestEnv(); + await mount(Root, getFixture(), { env, props }); + return fixture.querySelector(".o_datetime_input"); +}; + +class Root extends Component { + static components = { DateTimeInput }; + + static template = xml` + + + + + `; + + setup() { + this.mainComponentEntries = mainComponentRegistry.getEntries(); + } +} + +const mainComponentRegistry = registry.category("main_components"); +const serviceRegistry = registry.category("services"); + +let fixture; + +QUnit.module("Components", ({ beforeEach }) => { + beforeEach(() => { + clearRegistryWithCleanup(mainComponentRegistry); + + serviceRegistry + .add("hotkey", hotkeyService) + .add( + "localization", + makeFakeLocalizationService({ + dateFormat: "dd/MM/yyyy", + dateTimeFormat: "dd/MM/yyyy HH:mm:ss", + }) + ) + .add("popover", popoverService) + .add("ui", uiService); + + fixture = getFixture(); + }); + + QUnit.module("DateTimeInput (date)"); + + QUnit.test("basic rendering", async function (assert) { + await mountInput({ + value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"), + type: "date", + }); + + assert.containsOnce(fixture, ".o_datetime_input"); + assertDateTimePicker(false); + + const input = fixture.querySelector(".o_datetime_input"); + assert.strictEqual(input.value, "09/01/1997", "Value should be the one given"); + + await click(input); + + assertDateTimePicker({ + title: "January 1997", + date: [ + { + cells: [ + [-29, -30, -31, 1, 2, 3, 4], + [5, 6, 7, 8, [9], 10, 11], + [12, 13, 14, 15, 16, 17, 18], + [19, 20, 21, 22, 23, 24, 25], + [26, 27, 28, 29, 30, 31, -1], + [-2, -3, -4, -5, -6, -7, -8], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [1, 2, 3, 4, 5, 6], + }, + ], + }); + }); + + QUnit.test("pick a date", async function (assert) { + assert.expect(5); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"), + type: "date", + onChange: (date) => { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy"), + "08/02/1997", + "Event should transmit the correct date" + ); + }, + }); + + await click(input); + await click(getFixture(), ".o_datetime_picker .o_next"); // next month + + assert.verifySteps([]); + + await click(getPickerCell("8").at(0)); + + assert.strictEqual(input.value, "08/02/1997"); + assert.verifySteps(["datetime-changed"]); + }); + + QUnit.test("pick a date with FR locale", async function (assert) { + assert.expect(5); + + patchWithCleanup(luxon.Settings, { defaultLocale: "fr-FR" }); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"), + type: "date", + format: "dd MMM, yyyy", + onChange: (date) => { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy"), + "01/09/1997", + "Event should transmit the correct date" + ); + }, + }); + + assert.strictEqual(input.value, "09 janv., 1997"); + + await click(input); + await zoomOut(); + await click(getPickerCell("sept.")); + await click(getPickerCell("1").at(0)); + + assert.strictEqual(input.value, "01 sept., 1997"); + assert.verifySteps(["datetime-changed"]); + }); + + QUnit.test("pick a date with locale (locale with different symbols)", async function (assert) { + assert.expect(6); + + patchWithCleanup(luxon.Settings, { defaultLocale: "gu" }); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"), + type: "date", + format: "dd MMM, yyyy", + onChange: (date) => { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy"), + "01/09/1997", + "Event should transmit the correct date" + ); + }, + }); + + assert.strictEqual(input.value, "09 જાન્યુ, 1997"); + + await click(input); + + assert.strictEqual(input.value, "09 જાન્યુ, 1997"); + + await zoomOut(); + await click(getPickerCell("સપ્ટે")); + await click(getPickerCell("1").at(0)); + + assert.strictEqual(input.value, "01 સપ્ટે, 1997"); + assert.verifySteps(["datetime-changed"]); + }); + + QUnit.test("enter a date value", async function (assert) { + assert.expect(5); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"), + type: "date", + onChange: (date) => { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy"), + "08/02/1997", + "Event should transmit the correct date" + ); + }, + }); + + assert.verifySteps([]); + + await editInput(input, null, "08/02/1997"); + + assert.verifySteps(["datetime-changed"]); + + await click(input); + + assert.hasClass(getPickerCell("8").at(0), "o_active"); + }); + + QUnit.test("Date format is correctly set", async function (assert) { + assert.expect(2); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"), + type: "date", + format: "yyyy/MM/dd", + }); + + assert.strictEqual(input.value, "1997/01/09"); + + // Forces an update to assert that the registered format is the correct one + await click(input); + + assert.strictEqual(input.value, "1997/01/09"); + }); + + QUnit.module("DateTimeInput (datetime)"); + + QUnit.test("basic rendering", async function (assert) { + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + type: "datetime", + }); + + assert.containsOnce(fixture, ".o_datetime_input"); + assertDateTimePicker(false); + + assert.strictEqual(input.value, "09/01/1997 12:30:01", "Value should be the one given"); + + await click(input); + + assertDateTimePicker({ + title: "January 1997", + date: [ + { + cells: [ + [-29, -30, -31, 1, 2, 3, 4], + [5, 6, 7, 8, [9], 10, 11], + [12, 13, 14, 15, 16, 17, 18], + [19, 20, 21, 22, 23, 24, 25], + [26, 27, 28, 29, 30, 31, -1], + [-2, -3, -4, -5, -6, -7, -8], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [1, 2, 3, 4, 5, 6], + }, + ], + time: [[12, 30]], + }); + }); + + QUnit.test("pick a date and time", async function (assert) { + assert.expect(6); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + type: "datetime", + onChange: (date) => assert.step(date.toSQL().split(".")[0]), + }); + + assert.strictEqual(input.value, "09/01/1997 12:30:01"); + + await click(input); + + // Select February 8th + await click(getFixture(), ".o_datetime_picker .o_next"); + await click(getPickerCell("8").at(0)); + + // Select 15:45 + const [hourSelect, minuteSelect] = getTimePickers().at(0); + await editSelect(hourSelect, null, "15"); + await editSelect(minuteSelect, null, "45"); + + assert.strictEqual(input.value, "08/02/1997 15:45:01"); + assert.verifySteps(["1997-02-08 12:30:01", "1997-02-08 15:30:01", "1997-02-08 15:45:01"]); + }); + + QUnit.test("pick a date and time with locale", async function (assert) { + assert.expect(6); + + patchWithCleanup(luxon.Settings, { defaultLocale: "fr-FR" }); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + type: "datetime", + format: "dd MMM, yyyy HH:mm:ss", + onChange: (date) => assert.step(date.toSQL().split(".")[0]), + }); + + assert.strictEqual(input.value, "09 janv., 1997 12:30:01"); + + await click(input); + + // Select September 1st + await zoomOut(); + await click(getPickerCell("sept.")); + await click(getPickerCell("1").at(0)); + + // Select 15:45 + const [hourSelect, minuteSelect] = getTimePickers().at(0); + await editSelect(hourSelect, null, "15"); + await editSelect(minuteSelect, null, "45"); + + assert.strictEqual(input.value, "01 sept., 1997 15:45:01"); + assert.verifySteps(["1997-09-01 12:30:01", "1997-09-01 15:30:01", "1997-09-01 15:45:01"]); + }); + + QUnit.test("pick a time with 12 hour format without meridiem", async function (assert) { + assert.expect(3); + + patchWithCleanup(localization, { + dateFormat: "dd/MM/yyyy", + dateTimeFormat: "dd/MM/yyyy hh:mm:ss", + timeFormat: "hh:mm:ss", + }); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997 08:30:01", "dd/MM/yyyy HH:mm:ss"), + type: "datetime", + onChange: (date) => assert.step(date.toSQL().split(".")[0]), + }); + + assert.strictEqual(input.value, "09/01/1997 08:30:01"); + + await click(input); + + const [, minuteSelect] = getTimePickers().at(0); + await editSelect(minuteSelect, null, "15"); + + assert.verifySteps(["1997-01-09 08:15:01"]); + }); + + QUnit.test("enter a datetime value", async function (assert) { + assert.expect(7); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + type: "datetime", + onChange: (date) => { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy HH:mm:ss"), + "08/02/1997 15:45:05", + "Event should transmit the correct date" + ); + }, + }); + + assert.verifySteps([]); + + input.value = "08/02/1997 15:45:05"; + await triggerEvent(fixture, ".o_datetime_input", "change"); + + assert.verifySteps(["datetime-changed"]); + + await click(input); + + assert.strictEqual(input.value, "08/02/1997 15:45:05"); + assert.hasClass(getPickerCell("8").at(0), "o_active"); + assert.deepEqual(getTimePickers({ parse: true }).at(0), [15, 45]); + }); + + QUnit.test("Date time format is correctly set", async function (assert) { + assert.expect(2); + + const input = await mountInput({ + value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + type: "datetime", + format: "HH:mm:ss yyyy/MM/dd", + }); + + assert.strictEqual(input.value, "12:30:01 1997/01/09"); + + // Forces an update to assert that the registered format is the correct one + await click(input); + + assert.strictEqual(input.value, "12:30:01 1997/01/09"); + }); + + QUnit.test("Datepicker works with norwegian locale", async (assert) => { + assert.expect(6); + + patchWithCleanup(luxon.Settings, { defaultLocale: "nb-NO" }); + + const input = await mountInput({ + value: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"), + type: "datetime", + format: "dd MMM, yyyy", + onChange(date) { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy"), + "01/04/1997", + "Event should transmit the correct date" + ); + }, + }); + + assert.strictEqual(input.value, "09 apr., 1997"); + + await click(input); + + assert.strictEqual(input.value, "09 apr., 1997"); + + await click(getPickerCell("1").at(0)); + + assert.strictEqual(input.value, "01 apr., 1997"); + assert.verifySteps(["datetime-changed"]); + }); + + QUnit.test("Datepicker works with dots and commas in format", async (assert) => { + assert.expect(2); + + const input = await mountInput({ + value: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"), + type: "datetime", + format: "dd.MM,yyyy", + }); + + assert.strictEqual(input.value, "10.03,2023"); + + await click(input); + + assert.strictEqual(input.value, "10.03,2023"); + }); + + QUnit.test("start with no value", async function (assert) { + assert.expect(6); + + const input = await mountInput({ + type: "datetime", + onChange(date) { + assert.step("datetime-changed"); + assert.strictEqual( + date.toFormat("dd/MM/yyyy HH:mm:ss"), + "08/02/1997 15:45:05", + "Event should transmit the correct date" + ); + }, + }); + + assert.strictEqual(input.value, ""); + assert.verifySteps([]); + + await editInput(input, null, "08/02/1997 15:45:05"); + + assert.verifySteps(["datetime-changed"]); + assert.strictEqual(input.value, "08/02/1997 15:45:05"); + }); +}); diff --git a/addons/web/static/tests/core/datetime/datetime_picker_tests.js b/addons/web/static/tests/core/datetime/datetime_picker_tests.js new file mode 100644 index 00000000000..cfa2d6db04a --- /dev/null +++ b/addons/web/static/tests/core/datetime/datetime_picker_tests.js @@ -0,0 +1,715 @@ +/** @odoo-module **/ + +import { makeTestEnv } from "@web/../tests/helpers/mock_env"; +import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services"; +import { click, editSelect, getFixture, mount, patchDate } from "@web/../tests/helpers/utils"; +import { DateTimePicker } from "@web/core/datetime/datetime_picker"; +import { registry } from "@web/core/registry"; +import { ensureArray } from "@web/core/utils/arrays"; +import { + assertDateTimePicker, + getPickerCell, + getTexts, + getTimePickers, + useTwelveHourClockFormat, +} from "./datetime_test_helpers"; + +const { DateTime } = luxon; + +/** + * @typedef {import("@web/core/datetime/datetime_picker").DateTimePickerProps} DateTimePickerProps + */ + +/** + * @param {DateTimePickerProps["value"]} value + */ +const formatForStep = (value) => + ensureArray(value) + .map((val) => val.toISO().split(".")[0]) + .join(","); + +/** + * @param {DateTimePickerProps} [props] + */ +const mountPicker = (props) => mount(DateTimePicker, getFixture(), { env, props }); + +/** + * @param {any} value + */ +const pad2 = (value) => String(value).padStart(2, "0"); + +/** + * @template {any} [T=number] + * @param {number} length + * @param {(index: number) => T} mapping + */ +const range = (length, mapping) => [...Array(length)].map((_, i) => mapping(i)); + +let env; +let isSmall; +const serviceRegistry = registry.category("services"); + +QUnit.module("Components", ({ beforeEach }) => { + beforeEach(async () => { + patchDate(2023, 3, 25, 13, 45, 1); + + serviceRegistry.add( + "localization", + makeFakeLocalizationService({ + dateFormat: "dd/MM/yyyy", + timeFormat: "HH:mm:ss", + }) + ); + + isSmall = false; + env = await makeTestEnv(); + Object.defineProperty(env, "isSmall", { get: () => isSmall }); + }); + + QUnit.module("DateTimePicker"); + + //------------------------------------------------------------------------- + // Layout + //------------------------------------------------------------------------- + + QUnit.test("default params", async (assert) => { + await mountPicker(); + + assertDateTimePicker({ + title: "April 2023", + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, "25", 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [13, 14, 15, 16, 17, 18], + }, + ], + time: [[13, 0]], + }); + + const [hourSelect, minuteSelect] = getTimePickers().at(0); + assert.deepEqual(getTexts(hourSelect, "option"), range(24, String)); + assert.deepEqual( + getTexts(minuteSelect, "option"), + range(12, (i) => pad2(i * 5)) + ); + }); + + QUnit.test("twelve-hour clock", async (assert) => { + useTwelveHourClockFormat(); + + await mountPicker(); + + assertDateTimePicker({ + title: "April 2023", + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, "25", 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [13, 14, 15, 16, 17, 18], + }, + ], + time: [[1, 0, "PM"]], + }); + + const [hourSelect, minuteSelect, meridiemSelect] = getTimePickers().at(0); + assert.deepEqual(getTexts(hourSelect, "option"), ["12", ...range(12, String).slice(1)]); + assert.deepEqual( + getTexts(minuteSelect, "option"), + range(12, (i) => pad2(i * 5)) + ); + assert.deepEqual(getTexts(meridiemSelect, "option"), ["AM", "PM"]); + }); + + QUnit.test("hide time picker", async (assert) => { + await mountPicker({ + type: "date", + }); + + assertDateTimePicker({ + title: "April 2023", + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, "25", 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [13, 14, 15, 16, 17, 18], + }, + ], + }); + }); + + QUnit.test("focus is adjusted to selected date", async (assert) => { + await mountPicker({ + value: DateTime.fromObject({ month: 5, day: 29, hour: 23, minute: 55 }), + }); + + assertDateTimePicker({ + title: "May 2023", + date: [ + { + cells: [ + [-30, 1, 2, 3, 4, 5, 6], + [7, 8, 9, 10, 11, 12, 13], + [14, 15, 16, 17, 18, 19, 20], + [21, 22, 23, 24, 25, 26, 27], + [28, [29], 30, 31, -1, -2, -3], + [-4, -5, -6, -7, -8, -9, -10], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [18, 19, 20, 21, 22, 23], + }, + ], + time: [[23, 55]], + }); + }); + + QUnit.test("next month and previous month", async (assert) => { + await mountPicker({ + type: "date", + }); + + assertDateTimePicker({ + title: "April 2023", + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, "25", 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [13, 14, 15, 16, 17, 18], + }, + ], + }); + + await click(getFixture(), ".o_previous"); + + assertDateTimePicker({ + title: "March 2023", + date: [ + { + cells: [ + [-26, -27, -28, 1, 2, 3, 4], + [5, 6, 7, 8, 9, 10, 11], + [12, 13, 14, 15, 16, 17, 18], + [19, 20, 21, 22, 23, 24, 25], + [26, 27, 28, 29, 30, 31, -1], + [-2, -3, -4, -5, -6, -7, -8], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [9, 10, 11, 12, 13, 14], + }, + ], + }); + + await click(getFixture(), ".o_next"); + + assertDateTimePicker({ + title: "April 2023", + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, "25", 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [13, 14, 15, 16, 17, 18], + }, + ], + }); + + await click(getFixture(), ".o_next"); + + assertDateTimePicker({ + title: "May 2023", + date: [ + { + cells: [ + [-30, 1, 2, 3, 4, 5, 6], + [7, 8, 9, 10, 11, 12, 13], + [14, 15, 16, 17, 18, 19, 20], + [21, 22, 23, 24, 25, 26, 27], + [28, 29, 30, 31, -1, -2, -3], + [-4, -5, -6, -7, -8, -9, -10], + ], + daysOfWeek: ["#", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + weekNumbers: [18, 19, 20, 21, 22, 23], + }, + ], + }); + }); + + QUnit.test("additional month, hide time picker", async (assert) => { + await mountPicker({ + value: [ + DateTime.fromObject({ hour: 9, minute: 36 }), + DateTime.fromObject({ hour: 21, minute: 5 }), + ], + type: "date", + }); + + assertDateTimePicker({ + title: ["April 2023", "May 2023"], + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, ["25"], 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + { + cells: [ + [-30, 1, 2, 3, 4, 5, 6], + [7, 8, 9, 10, 11, 12, 13], + [14, 15, 16, 17, 18, 19, 20], + [21, 22, 23, 24, 25, 26, 27], + [28, 29, 30, 31, -1, -2, -3], + [-4, -5, -6, -7, -8, -9, -10], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + ], + }); + }); + + QUnit.test("additional month, empty range value", async (assert) => { + await mountPicker({ + value: [null, null], + }); + + assertDateTimePicker({ + title: ["April 2023", "May 2023"], + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, "25", 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + { + cells: [ + [-30, 1, 2, 3, 4, 5, 6], + [7, 8, 9, 10, 11, 12, 13], + [14, 15, 16, 17, 18, 19, 20], + [21, 22, 23, 24, 25, 26, 27], + [28, 29, 30, 31, -1, -2, -3], + [-4, -5, -6, -7, -8, -9, -10], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + ], + time: [ + [13, 0], + [13, 0], + ], + }); + + const [firstTimePicker, secondTimePicker] = getTimePickers(); + assert.deepEqual(getTexts(firstTimePicker[0], "option"), range(24, String)); + assert.deepEqual( + getTexts(firstTimePicker[1], "option"), + range(12, (i) => pad2(i * 5)) + ); + assert.deepEqual(getTexts(secondTimePicker[0], "option"), range(24, String)); + assert.deepEqual( + getTexts(secondTimePicker[1], "option"), + range(12, (i) => pad2(i * 5)) + ); + }); + + QUnit.test("range value", async (assert) => { + await mountPicker({ + value: [ + DateTime.fromObject({ day: 5, hour: 17, minute: 18 }), + DateTime.fromObject({ month: 5, day: 18, hour: 5, minute: 25 }), + ], + }); + + assertDateTimePicker({ + title: ["April 2023", "May 2023"], + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, [5], [6], [7], [8]], + [[9], [10], [11], [12], [13], [14], [15]], + [[16], [17], [18], [19], [20], [21], [22]], + [[23], [24], ["25"], [26], [27], [28], [29]], + [[30], -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + { + cells: [ + [-30, [1], [2], [3], [4], [5], [6]], + [[7], [8], [9], [10], [11], [12], [13]], + [[14], [15], [16], [17], [18], 19, 20], + [21, 22, 23, 24, 25, 26, 27], + [28, 29, 30, 31, -1, -2, -3], + [-4, -5, -6, -7, -8, -9, -10], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + ], + time: [ + [17, 0], + [5, 25], + ], + }); + + const [firstTimePicker, secondTimePicker] = getTimePickers(); + assert.deepEqual(getTexts(firstTimePicker[0], "option"), range(24, String)); + assert.deepEqual( + getTexts(firstTimePicker[1], "option"), + range(12, (i) => pad2(i * 5)) + ); + assert.deepEqual(getTexts(secondTimePicker[0], "option"), range(24, String)); + assert.deepEqual( + getTexts(secondTimePicker[1], "option"), + range(12, (i) => pad2(i * 5)) + ); + }); + + QUnit.test("range value on small device", async (assert) => { + isSmall = true; + + await mountPicker({ + value: [ + DateTime.fromObject({ hour: 9, minute: 36 }), + DateTime.fromObject({ hour: 21, minute: 5 }), + ], + }); + + assertDateTimePicker({ + title: "April 2023", + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, ["25"], 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + ], + time: [ + [9, 0], + [21, 5], + ], + }); + + const [firstTimePicker, secondTimePicker] = getTimePickers(); + assert.deepEqual(getTexts(firstTimePicker[0], "option"), range(24, String)); + assert.deepEqual( + getTexts(firstTimePicker[1], "option"), + range(12, (i) => pad2(i * 5)) + ); + assert.deepEqual(getTexts(secondTimePicker[0], "option"), range(24, String)); + assert.deepEqual( + getTexts(secondTimePicker[1], "option"), + range(12, (i) => pad2(i * 5)) + ); + }); + + QUnit.test("range value, previous month", async (assert) => { + await mountPicker({ + value: [false, false], + }); + + assertDateTimePicker({ + title: ["April 2023", "May 2023"], + date: [ + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, "25", 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + { + cells: [ + [-30, 1, 2, 3, 4, 5, 6], + [7, 8, 9, 10, 11, 12, 13], + [14, 15, 16, 17, 18, 19, 20], + [21, 22, 23, 24, 25, 26, 27], + [28, 29, 30, 31, -1, -2, -3], + [-4, -5, -6, -7, -8, -9, -10], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + ], + time: [ + [13, 0], + [13, 0], + ], + }); + + await click(getFixture(), ".o_previous"); + + assertDateTimePicker({ + title: ["March 2023", "April 2023"], + date: [ + { + cells: [ + [-26, -27, -28, 1, 2, 3, 4], + [5, 6, 7, 8, 9, 10, 11], + [12, 13, 14, 15, 16, 17, 18], + [19, 20, 21, 22, 23, 24, 25], + [26, 27, 28, 29, 30, 31, -1], + [-2, -3, -4, -5, -6, -7, -8], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + { + cells: [ + [-26, -27, -28, -29, -30, -31, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, "25", 26, 27, 28, 29], + [30, -1, -2, -3, -4, -5, -6], + ], + daysOfWeek: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + }, + ], + time: [ + [13, 0], + [13, 0], + ], + }); + }); + + //------------------------------------------------------------------------- + // Props and interactions + //------------------------------------------------------------------------- + + QUnit.test("different rounding", async (assert) => { + await mountPicker({ + rounding: 10, + }); + + const [hourSelect, minuteSelect] = getTimePickers().at(0); + assert.deepEqual(getTexts(hourSelect, "option"), range(24, String)); + assert.deepEqual( + getTexts(minuteSelect, "option"), + range(6, (i) => pad2(i * 10)) + ); + }); + + QUnit.test("no value, select date without handler", async (assert) => { + await mountPicker(); + + await click(getPickerCell("12")); + + assert.verifySteps([]); // This test just asserts that nothing happens + }); + + QUnit.test("no value, select date", async (assert) => { + await mountPicker({ + onSelect: (value) => assert.step(formatForStep(value)), + }); + + await click(getPickerCell("5").at(0)); + await click(getPickerCell("12")); + + assert.verifySteps(["2023-04-05T13:00:00", "2023-04-12T13:00:00"]); + }); + + QUnit.test("no value, select time", async (assert) => { + await mountPicker({ + onSelect: (value) => assert.step(formatForStep(value)), + }); + + const [hourSelect, minuteSelect] = getTimePickers().at(0); + await editSelect(hourSelect, null, "18"); + await editSelect(minuteSelect, null, "5"); + + assert.verifySteps(["2023-04-25T18:00:00", "2023-04-25T18:05:00"]); + }); + + QUnit.test("single value, select date", async (assert) => { + await mountPicker({ + value: DateTime.fromObject({ day: 30, hour: 8, minute: 43 }), + onSelect: (value) => assert.step(formatForStep(value)), + }); + + await click(getPickerCell("5").at(0)); + + assert.verifySteps(["2023-04-05T08:43:00"]); + }); + + QUnit.test("single value, select time", async (assert) => { + await mountPicker({ + value: DateTime.fromObject({ day: 30, hour: 8, minute: 43 }), + onSelect: (value) => assert.step(formatForStep(value)), + }); + + const [hourSelect, minuteSelect] = getTimePickers().at(0); + await editSelect(hourSelect, null, "18"); + await editSelect(minuteSelect, null, "5"); + + assert.verifySteps(["2023-04-30T18:43:00", "2023-04-30T18:05:00"]); + }); + + QUnit.test("single value, select time in twelve-hour clock format", async (assert) => { + useTwelveHourClockFormat(); + + await mountPicker({ + value: DateTime.fromObject({ day: 30, hour: 8, minute: 43 }), + onSelect: (value) => assert.step(formatForStep(value)), + }); + + const [hourSelect, minuteSelect, meridiemSelect] = getTimePickers().at(0); + await editSelect(hourSelect, null, "7"); + await editSelect(minuteSelect, null, "5"); + await editSelect(meridiemSelect, null, "PM"); + + assert.verifySteps(["2023-04-30T07:43:00", "2023-04-30T07:05:00", "2023-04-30T19:05:00"]); + }); + + QUnit.test("range value, select date for first value", async (assert) => { + await mountPicker({ + value: [ + DateTime.fromObject({ day: 20, hour: 8, minute: 43 }), + DateTime.fromObject({ day: 23, hour: 17, minute: 16 }), + ], + // focusedDateIndex is implicitly 0 + onSelect: (values) => assert.step(formatForStep(values)), + }); + + await click(getPickerCell("5").at(0)); + + assert.verifySteps(["2023-04-05T08:43:00,2023-04-23T17:16:00"]); + }); + + QUnit.test("range value, select time for first value", async (assert) => { + await mountPicker({ + value: [ + DateTime.fromObject({ day: 20, hour: 8, minute: 43 }), + DateTime.fromObject({ day: 23, hour: 17, minute: 16 }), + ], + focusedDateIndex: 0, + onSelect: (values) => assert.step(formatForStep(values)), + }); + + const [hourSelect, minuteSelect] = getTimePickers().at(0); + await editSelect(hourSelect, null, "18"); + await editSelect(minuteSelect, null, "5"); + + assert.verifySteps([ + "2023-04-20T18:43:00,2023-04-23T17:16:00", + "2023-04-20T18:05:00,2023-04-23T17:16:00", + ]); + }); + + QUnit.test("range value, select date for second value", async (assert) => { + await mountPicker({ + value: [ + DateTime.fromObject({ day: 20, hour: 8, minute: 43 }), + DateTime.fromObject({ day: 23, hour: 17, minute: 16 }), + ], + focusedDateIndex: 1, + onSelect: (values) => assert.step(formatForStep(values)), + }); + + await click(getPickerCell("21").at(0)); + + assert.verifySteps(["2023-04-20T08:43:00,2023-04-21T17:16:00"]); + }); + + QUnit.test("range value, select time for second value", async (assert) => { + await mountPicker({ + value: [ + DateTime.fromObject({ day: 20, hour: 8, minute: 43 }), + DateTime.fromObject({ day: 23, hour: 17, minute: 16 }), + ], + focusedDateIndex: 1, + onSelect: (values) => assert.step(formatForStep(values)), + }); + + const [hourSelect, minuteSelect] = getTimePickers().at(1); + await editSelect(hourSelect, null, "18"); + await editSelect(minuteSelect, null, "5"); + + assert.verifySteps([ + "2023-04-20T08:43:00,2023-04-23T18:16:00", + "2023-04-20T08:43:00,2023-04-23T18:05:00", + ]); + }); + + QUnit.test("range value, select date for second value before first value", async (assert) => { + await mountPicker({ + value: [ + DateTime.fromObject({ day: 20, hour: 8, minute: 43 }), + DateTime.fromObject({ day: 23, hour: 17, minute: 16 }), + ], + focusedDateIndex: 1, + onSelect: (values) => assert.step(formatForStep(values)), + }); + + await click(getPickerCell("19").at(0)); + + assert.verifySteps(["2023-04-19T08:43:00,2023-04-23T17:16:00"]); + }); + + QUnit.test("range value, select date for first value after second value", async (assert) => { + await mountPicker({ + value: [ + DateTime.fromObject({ day: 20, hour: 8, minute: 43 }), + DateTime.fromObject({ day: 23, hour: 17, minute: 16 }), + ], + focusedDateIndex: 0, + onSelect: (values) => assert.step(formatForStep(values)), + }); + + await click(getPickerCell("27").at(1)); + + assert.verifySteps(["2023-04-20T08:43:00,2023-04-27T17:16:00"]); + }); +}); diff --git a/addons/web/static/tests/core/datetime/datetime_test_helpers.js b/addons/web/static/tests/core/datetime/datetime_test_helpers.js new file mode 100644 index 00000000000..69325c85403 --- /dev/null +++ b/addons/web/static/tests/core/datetime/datetime_test_helpers.js @@ -0,0 +1,216 @@ +/** @odoo-module **/ + +import { patchWithCleanup } from "@web/../tests/helpers/utils"; +import { localization } from "@web/core/l10n/localization"; +import { ensureArray } from "@web/core/utils/arrays"; +import { click, getFixture } from "../../helpers/utils"; + +/** + * @typedef {import("@web/core/datetime/datetime_picker").DateTimePickerProps} DateTimePickerProps + */ + +/** + * @param {false | { + * title?: string | string[], + * date?: { + * cells: (number | string | [number] | [string])[][], + * daysOfWeek?: string[], + * weekNumbers?: number[], + * }[], + * time?: ([number, number] | [number, number, "AM" | "PM"])[], + * }} expectedParams + */ +export function assertDateTimePicker(expectedParams) { + const assert = QUnit.assert; + const fixture = getFixture(); + + // Check for picker in DOM + if (expectedParams) { + assert.containsOnce(fixture, ".o_datetime_picker"); + } else { + assert.containsNone(fixture, ".o_datetime_picker"); + return; + } + + const { title, date, time } = expectedParams; + + // Title + if (title) { + const expectedTitle = ensureArray(title); + assert.containsOnce(fixture, ".o_datetime_picker_header"); + assert.deepEqual( + getTexts(".o_datetime_picker_header", "h5"), + expectedTitle, + `title should be "${expectedTitle.join(" - ")}"` + ); + } else { + assert.containsNone(fixture, ".o_datetime_picker_header"); + } + + // Time picker + if (time) { + assert.containsN(fixture, ".o_time_picker", time.length); + const timePickers = select(".o_time_picker"); + for (let i = 0; i < time.length; i++) { + const expectedTime = time[i]; + const values = select(timePickers[i], ".o_time_picker_select").map((sel) => sel.value); + const actual = [...values.slice(0, 2).map(Number), ...values.slice(2)]; + assert.deepEqual(actual, expectedTime, `time values should be [${expectedTime}]`); + } + } else { + assert.containsNone(fixture, ".o_time_picker"); + } + + // Date picker + const datePickerEls = select(".o_date_picker"); + assert.containsN(fixture, ".o_date_picker", date.length); + + let activeCells = 0; + let outOfRangeCells = 0; + let todayCells = 0; + for (let i = 0; i < date.length; i++) { + const { cells, daysOfWeek, weekNumbers } = date[i]; + const datePickerEl = datePickerEls[i]; + const cellEls = select(datePickerEl, ".o_date_item_cell"); + + assert.strictEqual( + cellEls.length, + PICKER_ROWS * PICKER_COLS, + `picker should have ${ + PICKER_ROWS * PICKER_COLS + } cells (${PICKER_ROWS} rows and ${PICKER_COLS} columns)` + ); + + if (daysOfWeek) { + const actualDow = getTexts(datePickerEl, ".o_day_of_week_cell"); + assert.deepEqual( + actualDow, + daysOfWeek, + `picker should display the days of week: ${daysOfWeek + .map((dow) => `"${dow}"`) + .join(", ")}` + ); + } + + if (weekNumbers) { + assert.deepEqual( + getTexts(datePickerEl, ".o_week_number_cell").map(Number), + weekNumbers, + `picker should display the week numbers (${weekNumbers.join(", ")})` + ); + } + + // Date cells + const expectedCells = cells.flatMap((row, rowIndex) => + row.map((cell, colIndex) => { + const cellEl = cellEls[rowIndex * PICKER_COLS + colIndex]; + + // Check flags + let value = cell; + const isActive = Array.isArray(cell); + if (isActive) { + value = value[0]; + } + const isToday = typeof value === "string"; + if (isToday) { + value = Number(value); + } + const isOutOfRange = value < 0; + if (isOutOfRange) { + value = Math.abs(value); + } + + // Assert based on flags + if (isActive) { + activeCells++; + assert.hasClass(cellEl, "o_active"); + } + if (isOutOfRange) { + outOfRangeCells++; + assert.hasClass(cellEl, "o_out_of_range"); + } + if (isToday) { + todayCells++; + assert.hasClass(cellEl, "o_today"); + } + + return value; + }) + ); + + assert.deepEqual( + cellEls.map((cell) => Number(getTexts(cell)[0])), + expectedCells, + `cell content should match the expected values: [${expectedCells.join(", ")}]` + ); + } + + assert.containsN(fixture, ".o_active", activeCells); + assert.containsN(fixture, ".o_out_of_range", outOfRangeCells); + assert.containsN(fixture, ".o_today", todayCells); +} + +export function getPickerApplyButton() { + return select(".o_datetime_picker .o_datetime_buttons .o_apply").at(0); +} + +/** + * @param {RegExp | string} expr + */ +export function getPickerCell(expr) { + const regex = expr instanceof RegExp ? expr : new RegExp(`^${expr}$`, "i"); + const cells = select(".o_datetime_picker .o_date_item_cell").filter((cell) => + regex.test(getTexts(cell)[0]) + ); + return cells.length === 1 ? cells[0] : cells; +} + +/** + * @param {...(string | HTMLElement)} selectors + * @returns {string[]} + */ +export function getTexts(...selectors) { + return select(...selectors).map((e) => e.innerText.trim().replace(/\s+/g, " ")); +} + +/** + * @param {Object} [options={}] + * @param {boolean} [options.parse=false] whether to directly return the parsed + * values of the select elements + * @returns {HTMLSelectElement[] | (number | string)[]} + */ +export function getTimePickers({ parse = false } = {}) { + return select(".o_time_picker").map((timePickerEl) => { + const selects = select(timePickerEl, ".o_time_picker_select"); + if (parse) { + return selects.map((sel) => (isNaN(sel.value) ? sel.value : Number(sel.value))); + } else { + return selects; + } + }); +} + +/** + * @param {...(string | HTMLElement)} selectors + * @returns {HTMLElement[]} + */ +const select = (...selectors) => { + const root = selectors[0] instanceof Element ? selectors.shift() : getFixture(); + return selectors.length ? [...root.querySelectorAll(selectors.join(" "))] : [root]; +}; + +export function useTwelveHourClockFormat() { + const { dateFormat = "dd/MM/yyyy", timeFormat = "HH:mm:ss" } = localization; + const twcTimeFormat = `${timeFormat.replace(/H/g, "h")} a`; + patchWithCleanup(localization, { + dateTimeFormat: `${dateFormat} ${twcTimeFormat}`, + timeFormat: twcTimeFormat, + }); +} + +export function zoomOut() { + return click(getFixture(), ".o_zoom_out"); +} + +const PICKER_ROWS = 6; +const PICKER_COLS = 7; diff --git a/addons/web/static/tests/core/domain_selector_tests.js b/addons/web/static/tests/core/domain_selector_tests.js index 6c708664b43..869cc531fe1 100644 --- a/addons/web/static/tests/core/domain_selector_tests.js +++ b/addons/web/static/tests/core/domain_selector_tests.js @@ -14,6 +14,7 @@ import { registerCleanup } from "../helpers/cleanup"; import { makeTestEnv } from "../helpers/mock_env"; import { makeFakeLocalizationService } from "../helpers/mock_services"; import { click, editInput, editSelect, getFixture, mount, triggerEvent } from "../helpers/utils"; +import { getPickerApplyButton, getPickerCell } from "./datetime/datetime_test_helpers"; import { openModelFieldSelectorPopover } from "./model_field_selector_tests"; let serverData; @@ -218,23 +219,17 @@ QUnit.module("Components", (hooks) => { await mountComponent(Parent); // Check that there is a datepicker to choose the date - assert.containsOnce(target, ".o_datepicker", "there should be a datepicker"); + assert.containsOnce(target, ".o_datetime_input", "there should be a datepicker"); // 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"); + assert.equal(target.querySelector(".o_datetime_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"]` - ) - ); // => February 26th - await click( - document.body.querySelector(`.bootstrap-datetimepicker-widget a[data-action="close"]`) - ); + await click(target, ".o_datetime_input"); + await click(getPickerCell("26").at(0)); // => February 26th + await click(getPickerApplyButton()); // 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"); + assert.equal(target.querySelector(".o_datetime_input").value, "02/26/2017 16:42:00"); }); QUnit.test("building a domain with a datetime: context_today()", async (assert) => { @@ -251,19 +246,17 @@ QUnit.module("Components", (hooks) => { }); // Check that there is a datepicker to choose the date - assert.containsOnce(target, ".o_datepicker", "there should be a datepicker"); + assert.containsOnce(target, ".o_datetime_input", "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"); + assert.equal(target.querySelector(".o_datetime_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]`) - ); + await click(target, ".o_datetime_input"); + await click(target); // The input field should continue displaying 'Invalid DateTime'. // The value is still invalid. - assert.equal(target.querySelector(".o_datepicker_input").value, "Invalid DateTime"); + assert.equal(target.querySelector(".o_datetime_input").value, "Invalid DateTime"); assert.verifySteps([]); }); diff --git a/addons/web/static/tests/core/dropdown_tests.js b/addons/web/static/tests/core/dropdown_tests.js index 9d2d582e308..d0f90973baa 100644 --- a/addons/web/static/tests/core/dropdown_tests.js +++ b/addons/web/static/tests/core/dropdown_tests.js @@ -1,7 +1,9 @@ /** @odoo-module **/ +import { App, Component, xml } from "@odoo/owl"; +import { templates } from "@web/core/assets"; import { browser } from "@web/core/browser/browser"; -import { DateTimePicker } from "@web/core/datepicker/datepicker"; +import { DateTimeInput } from "@web/core/datetime/datetime_input"; import { Dropdown } from "@web/core/dropdown/dropdown"; import { DropdownItem } from "@web/core/dropdown/dropdown_item"; import { hotkeyService } from "@web/core/hotkeys/hotkey_service"; @@ -21,9 +23,8 @@ import { triggerHotkey, } from "../helpers/utils"; import { makeParent } from "./tooltip/tooltip_service_tests"; -import { templates } from "@web/core/assets"; +import { getPickerCell } from "./datetime/datetime_test_helpers"; -import { App, Component, xml } from "@odoo/owl"; const serviceRegistry = registry.category("services"); let env; @@ -1146,10 +1147,10 @@ QUnit.module("Components", ({ beforeEach }) => { Dropdown toggler - + `; - MyComponent.components = { Dropdown, DateTimePicker }; + MyComponent.components = { DateTimeInput, Dropdown }; await makeParent(MyComponent); @@ -1158,20 +1159,20 @@ QUnit.module("Components", ({ beforeEach }) => { await click(target, ".dropdown-toggle"); assert.containsOnce(target, ".o-dropdown--menu"); - assert.containsNone(document.body, ".bootstrap-datetimepicker-widget"); - assert.strictEqual(target.querySelector(".o_datepicker_input").value, ""); + assert.containsNone(target, ".o_datetime_picker"); + assert.strictEqual(target.querySelector(".o_datetime_input").value, ""); - await click(target, ".o_datepicker_input"); + await click(target, ".o_datetime_input"); assert.containsOnce(target, ".o-dropdown--menu"); - assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget"); - assert.strictEqual(target.querySelector(".o_datepicker_input").value, ""); + assert.containsOnce(target, ".o_datetime_picker"); + assert.strictEqual(target.querySelector(".o_datetime_input").value, ""); - await click(document.querySelectorAll(".datepicker table td")[15]); // select some day + await click(getPickerCell("15")); // select some day assert.containsOnce(target, ".o-dropdown--menu"); - assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget"); - assert.notOk(target.querySelector(".o_datepicker_input").value === ""); + assert.containsOnce(target, ".o_datetime_picker"); + assert.notOk(target.querySelector(".o_datetime_input").value === ""); } ); diff --git a/addons/web/static/tests/views/calendar/calendar_view_tests.js b/addons/web/static/tests/views/calendar/calendar_view_tests.js index 1ef896bd2bc..d31e92734e0 100644 --- a/addons/web/static/tests/views/calendar/calendar_view_tests.js +++ b/addons/web/static/tests/views/calendar/calendar_view_tests.js @@ -5,6 +5,7 @@ import { defaultLocalization } from "../../helpers/mock_services"; import { click, editInput, + editSelect, getFixture, makeDeferred, nextTick, @@ -43,6 +44,7 @@ import { registry } from "@web/core/registry"; import { userService } from "@web/core/user_service"; import { CharField } from "@web/views/fields/char/char_field"; import { actionService } from "@web/webclient/actions/action_service"; +import { getTimePickers } from "../../core/datetime/datetime_test_helpers"; const fieldRegistry = registry.category("fields"); const preloadedDataRegistry = registry.category("preloadedData"); @@ -1622,44 +1624,12 @@ QUnit.module("Views", ({ beforeEach }) => { ); // use datepicker to enter a date: 12/13/2016 08:00:00 - await click(target, `.o_field_widget[name="start"] .o_datepicker .o_datepicker_input`); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]` - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .timepicker .timepicker-hour` - ); - await click( - document.body.querySelectorAll( - `.bootstrap-datetimepicker-widget .timepicker-hours td.hour` - )[8] - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]` - ); + await click(target, `.o_field_widget[name="start"] input`); + await editSelect(getTimePickers().at(0).at(0), null, "8"); // use datepicker to enter a date: 12/13/2016 10:00:00 - await click(target, `.o_field_widget[name="stop"] .o_datepicker .o_datepicker_input`); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]` - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .timepicker .timepicker-hour` - ); - await click( - document.body.querySelectorAll( - `.bootstrap-datetimepicker-widget .timepicker-hours td.hour` - )[10] - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]` - ); + await click(target, `.o_field_widget[name="stop"] input`); + await editSelect(getTimePickers().at(0).at(0), null, "10"); await click(target, ".modal-footer .o_form_button_save"); assert.strictEqual( @@ -1859,44 +1829,12 @@ QUnit.module("Views", ({ beforeEach }) => { ); // use datepicker to enter a date: 12/13/2016 08:00:00 - await click(target, `.o_field_widget[name="start"] .o_datepicker .o_datepicker_input`); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]` - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .timepicker .timepicker-hour` - ); - await click( - document.body.querySelectorAll( - `.bootstrap-datetimepicker-widget .timepicker-hours td.hour` - )[8] - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]` - ); + await click(target, `.o_field_widget[name="start"] input`); + await editSelect(getTimePickers().at(0).at(0), null, "8"); // use datepicker to enter a date: 12/13/2016 10:00:00 - await click(target, `.o_field_widget[name="stop"] .o_datepicker .o_datepicker_input`); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]` - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .timepicker .timepicker-hour` - ); - await click( - document.body.querySelectorAll( - `.bootstrap-datetimepicker-widget .timepicker-hours td.hour` - )[10] - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]` - ); + await click(target, `.o_field_widget[name="stop"] input`); + await editSelect(getTimePickers().at(0).at(0), null, "10"); await click(target, ".modal-footer button.btn-primary:not(.d-none)"); assert.strictEqual( @@ -3856,38 +3794,12 @@ QUnit.module("Views", ({ beforeEach }) => { await click(target.querySelector(`.o_field_widget[name="allday"] input`)); // use datepicker to enter a date: 12/20/2016 07:00:00 - await click(target, `.o_field_widget[name="start"] .o_datepicker .o_datepicker_input`); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]` - ); - await click(document.body, `.bootstrap-datetimepicker-widget .timepicker .timepicker-hour`); - await click( - document.body.querySelectorAll( - `.bootstrap-datetimepicker-widget .timepicker-hours td.hour` - )[7] - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]` - ); + await click(target, `.o_field_widget[name="start"] input`); + await editSelect(getTimePickers().at(0).at(0), null, "7"); // use datepicker to enter a date: 12/20/2016 19:00:00 - await click(target, `.o_field_widget[name="stop"] .o_datepicker .o_datepicker_input`); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="togglePicker"]` - ); - await click(document.body, `.bootstrap-datetimepicker-widget .timepicker .timepicker-hour`); - await click( - document.body.querySelectorAll( - `.bootstrap-datetimepicker-widget .timepicker-hours td.hour` - )[19] - ); - await click( - document.body, - `.bootstrap-datetimepicker-widget .picker-switch a[data-action="close"]` - ); + await click(target, `.o_field_widget[name="stop"] input`); + await editSelect(getTimePickers().at(0).at(0), null, "19"); await click(target.querySelector(".modal .o_form_button_save")); diff --git a/addons/web/static/tests/views/fields/date_field_tests.js b/addons/web/static/tests/views/fields/date_field_tests.js index cd2df9ce7fa..0674200c53c 100644 --- a/addons/web/static/tests/views/fields/date_field_tests.js +++ b/addons/web/static/tests/views/fields/date_field_tests.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services"; +import { getPickerCell, zoomOut } from "@web/../tests/core/datetime/datetime_test_helpers"; import { click, clickCreate, @@ -16,8 +16,7 @@ import { triggerScroll, } from "@web/../tests/helpers/utils"; import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; -import { strftimeToLuxonFormat } from "@web/core/l10n/dates"; -import { registry } from "@web/core/registry"; +import { localization } from "@web/core/l10n/localization"; let serverData; let target; @@ -70,7 +69,7 @@ QUnit.module("Fields", (hooks) => { QUnit.module("DateField"); - QUnit.test("DateField: toggle datepicker [REQUIRE FOCUS]", async function (assert) { + QUnit.test("DateField: toggle datepicker", async (assert) => { await makeView({ type: "form", resModel: "partner", @@ -81,75 +80,56 @@ QUnit.module("Fields", (hooks) => { `, }); - assert.containsNone( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be closed initially" - ); + assert.containsNone(target, ".o_datetime_picker", "datepicker should be closed initially"); - await click(target, ".o_datepicker input"); - assert.containsOnce( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be opened" - ); + await click(target, ".o_field_date input"); + assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened"); // focus another field - target.querySelector(".o_field_widget[name='foo'] input").focus(); + await click(target, ".o_field_widget[name='foo'] input"); assert.containsNone( - document.body, - ".bootstrap-datetimepicker-widget", + target, + ".o_datetime_picker", "datepicker should close itself when the user clicks outside" ); }); - QUnit.test( - "DateField: toggle datepicker far in the future [REQUIRE FOCUS]", - async function (assert) { - serverData.models.partner.records = [ - { - id: 1, - date: "9999-12-30", - foo: "yop", - }, - ]; + QUnit.test("DateField: toggle datepicker far in the future", async (assert) => { + serverData.models.partner.records = [ + { + id: 1, + date: "9999-12-30", + foo: "yop", + }, + ]; - await makeView({ - type: "form", - resModel: "partner", - resId: 1, - serverData, - arch: ` + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
`, - }); + }); - assert.containsNone( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be closed initially" - ); + assert.containsNone(target, ".o_datetime_picker", "datepicker should be closed initially"); - await click(target, ".o_datepicker input"); - assert.containsOnce( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be opened" - ); + await click(target, ".o_field_date input"); + assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened"); - // focus another field - target.querySelector(".o_field_widget[name='foo'] input").focus(); - assert.containsNone( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should close itself when the user clicks outside" - ); - } - ); + // focus another field + await click(target, ".o_field_widget[name='foo'] input"); + assert.containsNone( + target, + ".o_datetime_picker", + "datepicker should close itself when the user clicks outside" + ); + }); - QUnit.test("date field is empty if no date is set", async function (assert) { + QUnit.test("date field is empty if no date is set", async (assert) => { await makeView({ type: "form", resModel: "partner", @@ -170,47 +150,24 @@ QUnit.module("Fields", (hooks) => { ); }); - QUnit.test( - "DateField: set an invalid date when the field is already set", - async function (assert) { - await makeView({ - type: "form", - resModel: "partner", - resId: 1, - serverData, - arch: '
', - }); + QUnit.test("DateField: set an invalid date when the field is already set", async (assert) => { + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: '
', + }); - const input = target.querySelector(".o_field_widget[name='date'] input"); - assert.strictEqual(input.value, "02/03/2017"); + const input = target.querySelector(".o_field_widget[name='date'] input"); + assert.strictEqual(input.value, "02/03/2017"); - input.value = "mmmh"; - await triggerEvent(input, null, "change"); - assert.strictEqual(input.value, "02/03/2017", "should have reset the original value"); - } - ); + input.value = "mmmh"; + await triggerEvent(input, null, "change"); + assert.strictEqual(input.value, "02/03/2017", "should have reset the original value"); + }); - QUnit.test( - "DateField: set an invalid date when the field is not set yet", - async function (assert) { - await makeView({ - type: "form", - resModel: "partner", - resId: 4, - serverData, - arch: '
', - }); - - const input = target.querySelector(".o_field_widget[name='date'] input"); - assert.strictEqual(input.value, ""); - - input.value = "mmmh"; - await triggerEvent(input, null, "change"); - assert.strictEqual(input.value, "", "The date field should be empty"); - } - ); - - QUnit.test("DateField value should not set on first click", async function (assert) { + QUnit.test("DateField: set an invalid date when the field is not set yet", async (assert) => { await makeView({ type: "form", resModel: "partner", @@ -219,25 +176,42 @@ QUnit.module("Fields", (hooks) => { arch: '
', }); - await click(target, ".o_datepicker input"); + const input = target.querySelector(".o_field_widget[name='date'] input"); + assert.strictEqual(input.value, ""); + + input.value = "mmmh"; + await triggerEvent(input, null, "change"); + assert.strictEqual(input.value, "", "The date field should be empty"); + }); + + QUnit.test("DateField value should not set on first click", async (assert) => { + await makeView({ + type: "form", + resModel: "partner", + resId: 4, + serverData, + arch: '
', + }); + + await click(target, ".o_field_date input"); // open datepicker and select a date assert.strictEqual( target.querySelector(".o_field_widget[name='date'] input").value, "", "date field's input should be empty on first click" ); - await click(document.body, ".day[data-day*='/22/']"); + await click(getPickerCell("22")); // re-open datepicker - await click(target, ".o_datepicker input"); + await click(target, ".o_field_date input"); assert.strictEqual( - document.body.querySelector(".day.active").textContent, + target.querySelector(".o_date_item_cell.o_active").textContent, "22", "datepicker should be highlight with 22nd day of month" ); }); - QUnit.test("DateField in form view (with positive time zone offset)", async function (assert) { + QUnit.test("DateField in form view (with positive time zone offset)", async (assert) => { assert.expect(7); patchTimeZone(120); // Should be ignored by date fields @@ -260,39 +234,28 @@ QUnit.module("Fields", (hooks) => { }); assert.strictEqual( - target.querySelector(".o_datepicker_input").value, + target.querySelector(".o_field_date input").value, "02/03/2017", "the date should be correct in edit mode" ); // open datepicker and select another value - await click(target, ".o_datepicker_input"); + await click(target, ".o_field_date input"); + assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened"); assert.containsOnce( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be opened" - ); - assert.containsOnce( - document.body, - ".day.active[data-day='02/03/2017']", - "datepicker should be highlight February 3" - ); - await click( - document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0] - ); - await click( - document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1] - ); - await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[8]); - await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[1]); - await click(document.body.querySelector(".day[data-day*='/22/']")); - assert.containsNone( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be closed" + target, + ".o_date_item_cell.o_active", + "datepicker should have a selected day" ); + // select 22 Feb 2017 + await zoomOut(); + await zoomOut(); + await click(getPickerCell("2017")); + await click(getPickerCell("Feb")); + await click(getPickerCell("22")); + assert.containsNone(target, ".o_datetime_picker", "datepicker should be closed"); assert.strictEqual( - target.querySelector(".o_datepicker_input").value, + target.querySelector(".o_field_date input").value, "02/22/2017", "the selected date should be displayed in the input" ); @@ -306,7 +269,7 @@ QUnit.module("Fields", (hooks) => { ); }); - QUnit.test("DateField in form view (with negative time zone offset)", async function (assert) { + QUnit.test("DateField in form view (with negative time zone offset)", async (assert) => { patchTimeZone(-120); // Should be ignored by date fields await makeView({ @@ -318,13 +281,13 @@ QUnit.module("Fields", (hooks) => { }); assert.strictEqual( - target.querySelector(".o_datepicker_input").value, + target.querySelector(".o_field_date input").value, "02/03/2017", "the date should be correct in edit mode" ); }); - QUnit.test("DateField dropdown disappears on scroll", async function (assert) { + QUnit.test("DateField dropdown doesn't disappear on scroll", async (assert) => { await makeView({ type: "form", resModel: "partner", @@ -338,22 +301,14 @@ QUnit.module("Fields", (hooks) => { `, }); - await click(target, ".o_datepicker input"); - assert.containsOnce( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be opened" - ); + await click(target, ".o_field_date input"); + assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened"); await triggerScroll(target, { top: 50 }); - assert.containsNone( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be closed" - ); + assert.containsOnce(target, ".o_datetime_picker", "datepicker should still be opened"); }); - QUnit.test("DateField with label opens datepicker on click", async function (assert) { + QUnit.test("DateField with label opens datepicker on click", async (assert) => { await makeView({ type: "form", resModel: "partner", @@ -367,14 +322,10 @@ QUnit.module("Fields", (hooks) => { }); await click(target.querySelector("label.o_form_label")); - assert.containsOnce( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be opened" - ); + assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened"); }); - QUnit.test("DateField with warn_future option", async function (assert) { + QUnit.test("DateField with warn_future option", async (assert) => { await makeView({ type: "form", resModel: "partner", @@ -382,42 +333,36 @@ QUnit.module("Fields", (hooks) => { serverData, arch: `
- + `, }); // open datepicker and select another value - await click(target, ".o_datepicker input"); - await click( - document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0] - ); - await click( - document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1] - ); - await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[11]); - await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[11]); - await click(document.body, ".day[data-day*='/31/']"); + await click(target, ".o_field_date input"); + await zoomOut(); + await zoomOut(); + await click(getPickerCell("2030")); + await click(getPickerCell("Dec")); + await click(getPickerCell("31")); assert.containsOnce( target, - ".o_datepicker_warning", + ".fa-exclamation-triangle", "should have a warning in the form view" ); - const input = target.querySelector(".o_field_widget[name='date'] input"); - input.value = ""; - await triggerEvent(input, null, "change"); // remove the value + await editInput(target, ".o_field_widget[name='date'] input", ""); assert.containsNone( target, - ".o_datepicker_warning", + ".fa-exclamation-triangle", "the warning in the form view should be hidden" ); }); QUnit.test( "DateField with warn_future option: do not overwrite datepicker option", - async function (assert) { + async (assert) => { // Making sure we don't have a legit default value // or any onchange that would set the value serverData.models.partner.fields.date.default = undefined; @@ -431,7 +376,7 @@ QUnit.module("Fields", (hooks) => { arch: `
- + `, }); @@ -450,7 +395,7 @@ QUnit.module("Fields", (hooks) => { } ); - QUnit.test("DateField in editable list view", async function (assert) { + QUnit.test("DateField in editable list view", async (assert) => { await makeView({ type: "list", resModel: "partner", @@ -468,45 +413,33 @@ QUnit.module("Fields", (hooks) => { assert.containsOnce( target, - "input.o_datepicker_input", + ".o_field_date input", "the view should have a date input for editable mode" ); assert.strictEqual( - target.querySelector("input.o_datepicker_input"), + target.querySelector(".o_field_date input"), document.activeElement, "date input should have the focus" ); assert.strictEqual( - target.querySelector("input.o_datepicker_input").value, + target.querySelector(".o_field_date input").value, "02/03/2017", "the date should be correct in edit mode" ); // open datepicker and select another value - await click(target, ".o_datepicker_input"); - assert.containsOnce( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be opened" - ); - await click( - document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[0] - ); - await click( - document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1] - ); - await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")[8]); - await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[1]); - await click(document.body.querySelector(".day[data-day*='/22/']")); - assert.containsNone( - document.body, - ".bootstrap-datetimepicker-widget", - "datepicker should be closed" - ); + await click(target, ".o_field_date input"); + assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened"); + await zoomOut(); + await zoomOut(); + await click(getPickerCell("2017")); + await click(getPickerCell("Feb")); + await click(getPickerCell("22")); + assert.containsNone(target, ".o_datetime_picker", "datepicker should be closed"); assert.strictEqual( - target.querySelector(".o_datepicker_input").value, + target.querySelector(".o_field_date input").value, "02/22/2017", "the selected date should be displayed in the input" ); @@ -520,44 +453,41 @@ QUnit.module("Fields", (hooks) => { ); }); - QUnit.test( - "multi edition of DateField in list view: clear date in input", - async function (assert) { - serverData.models.partner.records[1].date = "2017-02-03"; + QUnit.test("multi edition of DateField in list view: clear date in input", async (assert) => { + serverData.models.partner.records[1].date = "2017-02-03"; - await makeView({ - serverData, - type: "list", - resModel: "partner", - arch: '', - }); + await makeView({ + serverData, + type: "list", + resModel: "partner", + arch: '', + }); - const rows = target.querySelectorAll(".o_data_row"); + const rows = target.querySelectorAll(".o_data_row"); - // select two records and edit them - await click(rows[0], ".o_list_record_selector input"); - await click(rows[1], ".o_list_record_selector input"); + // select two records and edit them + await click(rows[0], ".o_list_record_selector input"); + await click(rows[1], ".o_list_record_selector input"); - await click(rows[0], ".o_data_cell"); + await click(rows[0], ".o_data_cell"); - assert.containsOnce(target, "input.o_datepicker_input"); - await editInput(target, ".o_datepicker_input", ""); + assert.containsOnce(target, ".o_field_date input"); + await editInput(target, ".o_field_date input", ""); - assert.containsOnce(document.body, ".modal"); - await click(target, ".modal .modal-footer .btn-primary"); + assert.containsOnce(target, ".modal"); + await click(target, ".modal .modal-footer .btn-primary"); - assert.strictEqual( - target.querySelector(".o_data_row:first-child .o_data_cell").textContent, - "" - ); - assert.strictEqual( - target.querySelector(".o_data_row:nth-child(2) .o_data_cell").textContent, - "" - ); - } - ); + assert.strictEqual( + target.querySelector(".o_data_row:first-child .o_data_cell").textContent, + "" + ); + assert.strictEqual( + target.querySelector(".o_data_row:nth-child(2) .o_data_cell").textContent, + "" + ); + }); - QUnit.test("DateField remove value", async function (assert) { + QUnit.test("DateField remove value", async (assert) => { await makeView({ type: "form", resModel: "partner", @@ -572,16 +502,16 @@ QUnit.module("Fields", (hooks) => { }); assert.strictEqual( - target.querySelector(".o_datepicker_input").value, + target.querySelector(".o_field_date input").value, "02/03/2017", "the date should be correct in edit mode" ); - const input = target.querySelector(".o_datepicker_input"); + const input = target.querySelector(".o_field_date input"); input.value = ""; await triggerEvents(input, null, ["input", "change", "focusout"]); assert.strictEqual( - target.querySelector(".o_datepicker_input").value, + target.querySelector(".o_field_date input").value, "", "should have correctly removed the value" ); @@ -595,93 +525,32 @@ QUnit.module("Fields", (hooks) => { ); }); - QUnit.test( - "do not trigger a field_changed for datetime field with date widget", - async function (assert) { - await makeView({ - type: "form", - resModel: "partner", - resId: 1, - serverData, - arch: '
', - mockRPC(route, { method }) { - assert.step(method); - }, - }); - - assert.strictEqual( - target.querySelector(".o_datepicker_input").value, - "02/08/2017", - "the date should be correct" - ); - - const input = target.querySelector(".o_field_widget[name='datetime'] input"); - input.value = "02/08/2017"; - await triggerEvents(input, null, ["input", "change", "focusout"]); - - assert.containsOnce(target, ".o_form_saved"); - assert.verifySteps(["get_views", "read"]); // should not have save as nothing changed - } - ); - - QUnit.test( - "field date should select its content onclick when there is one", - async function (assert) { - assert.expect(3); - const done = assert.async(); - - await makeView({ - type: "form", - resModel: "partner", - resId: 1, - serverData, - arch: '
', - }); - - $(target).on("show.datetimepicker", () => { - assert.containsOnce( - document.body, - ".bootstrap-datetimepicker-widget", - "bootstrap-datetimepicker is visible" - ); - const active = document.activeElement; - assert.strictEqual( - active.tagName, - "INPUT", - "The datepicker input should be focused" - ); - assert.strictEqual( - active.value.slice(active.selectionStart, active.selectionEnd), - "02/03/2017", - "The whole input of the date field should have been selected" - ); - done(); - }); - - await click(target, ".o_datepicker input"); - } - ); - - QUnit.test("DateField support internationalization", async function (assert) { - // The DatePicker component needs the locale to be available since it - // is still using Moment.js for the bootstrap datepicker - const originalLocale = moment.locale(); - moment.defineLocale("no", { - monthsShort: "jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.".split("_"), - monthsParseExact: true, - dayOfMonthOrdinalParse: /\d{1,2}\./, - ordinal: "%d.", + QUnit.test("field date should select its content onclick when there is one", async (assert) => { + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: '
', }); - registry.category("services").remove("localization"); - registry - .category("services") - .add( - "localization", - makeFakeLocalizationService({ dateFormat: strftimeToLuxonFormat("%d-%m/%Y") }) - ); - patchWithCleanup(luxon.Settings, { - defaultLocale: "no", + const input = target.querySelector(".o_field_date input"); + await click(input); + input.focus(); + + assert.containsOnce(target, ".o_datetime_picker"); + const active = document.activeElement; + assert.strictEqual(active.tagName, "INPUT", "The datepicker input should be focused"); + assert.strictEqual( + active.value.slice(active.selectionStart, active.selectionEnd), + "02/03/2017", + "The whole input of the date field should have been selected" + ); + }); + + QUnit.test("DateField supports custom format", async (assert) => { + patchWithCleanup(localization, { + dateFormat: "dd-MM-yyyy", }); await makeView({ @@ -693,27 +562,60 @@ QUnit.module("Fields", (hooks) => { }); const dateViewForm = target.querySelector(".o_field_date input").value; - await click(target, ".o_datepicker input"); + await click(target, ".o_field_date input"); assert.strictEqual( - target.querySelector(".o_datepicker_input").value, + target.querySelector(".o_field_date input").value, dateViewForm, "input date field should be the same as it was in the view form" ); - await click(document.body.querySelector(".day[data-day*='/22/']")); - const dateEditForm = target.querySelector(".o_datepicker_input").value; + await click(getPickerCell("22")); + const dateEditForm = target.querySelector(".o_field_date input").value; await clickSave(target); assert.strictEqual( target.querySelector(".o_field_date input").value, dateEditForm, "date field should be the same as the one selected in the view form" ); - - moment.locale(originalLocale); - moment.updateLocale("no", null); }); - QUnit.test("DateField: hit enter should update value", async function (assert) { + QUnit.test("DateField supports internationalization", async (assert) => { + patchWithCleanup(luxon.Settings, { + defaultLocale: "nb-NO", + }); + + await makeView({ + type: "form", + resModel: "partner", + serverData, + arch: '
', + resId: 1, + }); + + const dateViewForm = target.querySelector(".o_field_date input").value; + await click(target, ".o_field_date input"); + + assert.strictEqual( + target.querySelector(".o_field_date input").value, + dateViewForm, + "input date field should be the same as it was in the view form" + ); + assert.strictEqual( + target.querySelector(".o_zoom_out h5").textContent, + "februar 2017", + "Norwegian locale should be correctly applied" + ); + await click(getPickerCell("22")); + const dateEditForm = target.querySelector(".o_field_date input").value; + await clickSave(target); + assert.strictEqual( + target.querySelector(".o_field_date input").value, + dateEditForm, + "date field should be the same as the one selected in the view form" + ); + }); + + QUnit.test("DateField: hit enter should update value", async (assert) => { patchTimeZone(120); await makeView({ @@ -744,7 +646,7 @@ QUnit.module("Fields", (hooks) => { ); }); - QUnit.test("DateField: allow to use compute dates (+5d for instance)", async function (assert) { + QUnit.test("DateField: allow to use compute dates (+5d for instance)", async (assert) => { patchDate(2021, 1, 15, 10, 0, 0); // current date : 15 Feb 2021 10:00:00 serverData.models.partner.fields.date.default = "2019-09-15"; 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 04715c05e69..6044f695712 100644 --- a/addons/web/static/tests/views/fields/daterange_field_tests.js +++ b/addons/web/static/tests/views/fields/daterange_field_tests.js @@ -1,20 +1,27 @@ /** @odoo-module **/ +import { getPickerCell, getTimePickers } from "@web/../tests/core/datetime/datetime_test_helpers"; import { click, clickSave, editInput, getFixture, + nextTick, patchDate, patchTimeZone, - patchWithCleanup, triggerEvent, triggerScroll, } from "@web/../tests/helpers/utils"; -import { localization } from "@web/core/l10n/localization"; -import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; -import { registerCleanup } from "@web/../tests/helpers/cleanup"; import { pagerNext } from "@web/../tests/search/helpers"; +import { makeView, setupViewRegistries } from "@web/../tests/views/helpers"; + +/** + * @param {HTMLElement} el + */ +const isHiddenByCSS = (el) => { + const style = getComputedStyle(el); + return style.visibility === "hidden" || style.opacity === "0"; +}; let serverData; let target; @@ -28,6 +35,7 @@ QUnit.module("Fields", (hooks) => { fields: { date: { string: "A date", type: "date", searchable: true }, datetime: { string: "A datetime", type: "datetime", searchable: true }, + datetime_end: { string: "Datetime End", type: "datetime" }, }, records: [ { @@ -53,13 +61,250 @@ QUnit.module("Fields", (hooks) => { QUnit.module("DateRangeField"); + QUnit.test("Datetime field - interaction with the datepicker", async (assert) => { + serverData.models.partner.records[0].datetime_end = "2017-03-13 00:00:00"; + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + `, + }); + + // Check date range picker initialization + assert.containsOnce(getFixture(), ".o_field_daterange"); + assert.containsNone(getFixture(), ".o_datetime_picker"); + + // open the first one + const daterange = target.querySelector(".o_field_daterange"); + await click(daterange.querySelector("input[data-field=datetime]")); + + let datepicker = document.querySelector(".o_datetime_picker"); + assert.isVisible(datepicker, "first date range picker should be opened"); + assert.strictEqual( + datepicker.querySelector(".o_date_item_cell.o_active_start").textContent, + "8", + "active start date should be '8' in date range picker" + ); + let [hourSelectStart, minuteSelectStart] = getTimePickers().at(0); + assert.strictEqual( + hourSelectStart.value, + "15", + "active start date hour should be '15' in date range picker" + ); + assert.strictEqual( + minuteSelectStart.value, + "30", + "active start date minute should be '30' in date range picker" + ); + assert.strictEqual( + datepicker.querySelector(".o_date_item_cell.o_active_end").textContent, + "13", + "active end date should be '13' in date range picker" + ); + let [hourSelectEnd, minuteSelectEnd] = getTimePickers().at(1); + assert.strictEqual( + hourSelectEnd.value, + "5", + "active end date hour should be '5' in date range picker" + ); + assert.strictEqual( + minuteSelectEnd.value, + "30", + "active end date minute should be '30' in date range picker" + ); + assert.containsN( + minuteSelectStart, + "option", + 12, + "minute selection should contain 12 options (1 for each 5 minutes)" + ); + // Close picker + await click(document.querySelector(".o_form_view_container")); + assert.containsNone(getFixture(), ".o_datetime_picker", "datepicker should be closed"); + + // Try to check with end date + await click(daterange.querySelector("input[data-field=datetime_end]")); + + datepicker = document.querySelector(".o_datetime_picker"); + assert.isVisible(datepicker, "first date range picker should be opened"); + assert.strictEqual( + datepicker.querySelector(".o_date_item_cell.o_active_start").textContent, + "8", + "active start date should be '8' in date range picker" + ); + [hourSelectStart, minuteSelectStart] = getTimePickers().at(0); + assert.strictEqual( + hourSelectStart.value, + "15", + "active start date hour should be '15' in date range picker" + ); + assert.strictEqual( + minuteSelectStart.value, + "30", + "active start date minute should be '30' in date range picker" + ); + assert.strictEqual( + datepicker.querySelector(".o_date_item_cell.o_active_end").textContent, + "13", + "active end date should be '13' in date range picker" + ); + [hourSelectEnd, minuteSelectEnd] = getTimePickers().at(1); + assert.strictEqual( + hourSelectEnd.value, + "5", + "active end date hour should be '5' in date range picker" + ); + assert.strictEqual( + minuteSelectEnd.value, + "30", + "active end date minute should be '30' in date range picker" + ); + assert.containsN( + minuteSelectStart, + "option", + 12, + "minute selection should contain 12 options (1 for each 5 minutes)" + ); + + // Select a new range and check that inputs are updated + await click(getPickerCell("8").at(0)); // 02/08/2017 + await click(getPickerCell("9").at(0)); // 02/09/2017 + assert.equal( + target.querySelector("input[data-field=datetime]").value, + "02/08/2017 15:30:00" + ); + assert.equal( + target.querySelector("input[data-field=datetime_end]").value, + "02/09/2017 05:30:00" + ); + + // Save + await clickSave(target); + + // Check date after save + assert.strictEqual( + target.querySelector("input[data-field=datetime]").value, + "02/08/2017 15:30:00" + ); + assert.strictEqual( + target.querySelector("input[data-field=datetime_end]").value, + "02/09/2017 05:30:00" + ); + }); + + QUnit.test("Date field - interaction with the datepicker", async (assert) => { + serverData.models.partner.fields.date_end = { string: "Date End", type: "date" }; + serverData.models.partner.records[0].date_end = "2017-02-08"; + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: ` +
+ + `, + }); + + // Check date range picker initialization + assert.containsOnce(getFixture(), ".o_field_daterange"); + assert.containsNone(getFixture(), ".o_datetime_picker"); + + // open the first one + await click(target.querySelector("input[data-field=date]")); + let datepicker = document.querySelector(".o_datetime_picker"); + + assert.isVisible(datepicker, "first date range picker should be opened"); + assert.strictEqual( + datepicker.querySelector(".o_active_start").textContent, + "3", + "active start date should be '3' in date range picker" + ); + assert.strictEqual( + datepicker.querySelector(".o_active_end").textContent, + "8", + "active end date should be '8' in date range picker" + ); + + // Change date + await click(getPickerCell("12").at(1)); + await click(getPickerCell("16").at(0)); + // Close picker + await click(document.querySelector(".o_form_view")); + + // Check date after change + assert.isNotVisible(datepicker, "date range picker should be closed"); + assert.strictEqual( + target.querySelector("input[data-field=date]").value, + "02/16/2017", + "the date should be '02/16/2017'" + ); + assert.strictEqual( + target.querySelector("input[data-field=date_end]").value, + "03/12/2017", + "'the date should be '03/12/2017'" + ); + + // Try to change range with end date + await click(target.querySelector("input[data-field=date_end]")); + datepicker = document.querySelector(".o_datetime_picker"); + + assert.isVisible(datepicker, "date range picker should be opened"); + assert.strictEqual( + datepicker.querySelector(".o_active_start").textContent, + "16", + "start date should be a 16 in date range picker" + ); + assert.strictEqual( + datepicker.querySelector(".o_active_end").textContent, + "12", + "end date should be a 12 in date range picker" + ); + + // Change date + await click(getPickerCell("13").at(0)); + await click(getPickerCell("18").at(1)); + // Close picker + await click(document.querySelector(".o_form_view")); + + // Check date after change + assert.isNotVisible(datepicker, "date range picker should be closed"); + assert.strictEqual( + target.querySelector("input[data-field=date]").value, + "02/13/2017", + "the start date should be '02/13/2017'" + ); + assert.strictEqual( + target.querySelector("input[data-field=date_end]").value, + "03/18/2017", + "the end date should be '03/18/2017'" + ); + + // Save + await clickSave(target); + + // Check date after save + assert.strictEqual( + target.querySelector("input[data-field=date]").value, + "02/13/2017", + "the start date should be '02/13/2017' after save" + ); + assert.strictEqual( + target.querySelector("input[data-field=date_end]").value, + "03/18/2017", + "the end date should be '03/18/2017' after save" + ); + }); + QUnit.test( - "Datetime field - interaction with the datepicker [REQUIRE FOCUS]", - async function (assert) { - serverData.models.partner.fields.datetime_end = { - string: "Datetime End", - type: "datetime", - }; + "date picker should still be present when scrolling outside of it", + async (assert) => { serverData.models.partner.records[0].datetime_end = "2017-03-13 00:00:00"; await makeView({ @@ -69,323 +314,25 @@ QUnit.module("Fields", (hooks) => { serverData, arch: `
- - + `, }); - // Check date range picker initialization - assert.containsN( - document.body, - ".daterangepicker", - 2, - "should initialize 2 date range picker" - ); - const datepickers = document.querySelectorAll(`.daterangepicker`); - assert.isNotVisible( - datepickers[0], - "first date range picker should be closed initially" - ); - assert.isNotVisible( - datepickers[datepickers.length - 1], - "second date range picker should be closed initially" - ); - - // open the first one - let fields = target.querySelectorAll(".o_field_daterange"); - await click(fields[0].querySelector("input")); - - let datepicker = document.querySelector( - `.daterangepicker[data-name="${fields[0].getAttribute("name")}"]` - ); - assert.isVisible(datepicker, "first date range picker should be opened"); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.left .active.start-date").textContent, - "8", - "active start date should be '8' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.left .hourselect").value, - "15", - "active start date hour should be '15' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.left .minuteselect").value, - "30", - "active start date minute should be '30' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.right .active.end-date").textContent, - "13", - "active end date should be '13' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.right .hourselect").value, - "5", - "active end date hour should be '5' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.right .minuteselect").value, - "30", - "active end date minute should be '30' in date range picker" - ); - assert.containsN( - datepicker.querySelector(".drp-calendar.left .minuteselect"), - "option", - 12, - "minute selection should contain 12 options (1 for each 5 minutes)" - ); - // Close picker - await click(datepicker.querySelector(".cancelBtn")); - assert.strictEqual( - datepicker.style.display, - "none", - "date range picker should be closed" - ); - - // Try to check with end date - await click(fields[fields.length - 1], "input"); - datepicker = document.querySelector( - `.daterangepicker[data-name="${fields[fields.length - 1].getAttribute("name")}"]` - ); - - assert.isVisible(datepicker, "date range picker should be opened"); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.left .active.start-date").textContent, - "8", - "active start date should be '8' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.left .hourselect").value, - "15", - "active start date hour should be '15' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.left .minuteselect").value, - "30", - "active start date minute should be '30' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.right .active.end-date").textContent, - "13", - "active end date should be '13' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.right .hourselect").value, - "5", - "active end date hour should be '5' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".drp-calendar.right .minuteselect").value, - "30", - "active end date minute should be '30' in date range picker" - ); - - // Select a new range and check that inputs are updated - await triggerEvent(datepicker, ".start-date", "mousedown"); // 02/08/2017 - await triggerEvent(datepicker, ".start-date + .available", "mousedown"); // 02/09/2017 - await click(datepicker, ".applyBtn"); - assert.equal( - target.querySelector("[name=datetime] input").value, - "02/08/2017 15:30:00" - ); - assert.equal( - target.querySelector("[name=datetime_end] input").value, - "02/09/2017 05:30:00" - ); - - // Save - await clickSave(target); - fields = target.querySelectorAll(".o_field_daterange input"); - - // Check date after save - assert.strictEqual(fields[0].value, "02/08/2017 15:30:00"); - assert.strictEqual(fields[fields.length - 1].value, "02/09/2017 05:30:00"); - } - ); - - QUnit.test( - "Date field - interaction with the datepicker [REQUIRE FOCUS]", - async function (assert) { - serverData.models.partner.fields.date_end = { string: "Date End", type: "date" }; - serverData.models.partner.records[0].date_end = "2017-02-08"; - - await makeView({ - type: "form", - resModel: "partner", - resId: 1, - serverData, - arch: ` -
- - - `, - }); - - let fields = target.querySelectorAll(".o_field_daterange"); - const datepickers = document.querySelectorAll(`.daterangepicker`); - - // Check date range picker initialization - assert.containsN( - document.body, - ".daterangepicker", - 2, - "should initialize 2 date range picker" - ); - assert.isNotVisible( - datepickers[0], - "first date range picker should be closed initially" - ); - assert.isNotVisible( - datepickers[datepickers.length - 1], - "second date range picker should be closed initially" - ); - - // open the first one - await click(fields[0].querySelector("input")); - let datepicker = document.querySelector( - `.daterangepicker[data-name="${fields[0].getAttribute("name")}"]` - ); - - assert.isVisible(datepicker, "first date range picker should be opened"); - assert.strictEqual( - datepicker.querySelector(".active.start-date").textContent, - "3", - "active start date should be '3' in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".active.end-date").textContent, - "8", - "active end date should be '8' in date range picker" - ); - - // Change date - await triggerEvent( - datepicker, - ".drp-calendar.left .available[data-title='r2c4']", - "mousedown" - ); - await triggerEvent( - datepicker, - ".drp-calendar.right .available[data-title='r2c0']", - "mousedown" - ); - await click(datepicker.querySelector(".applyBtn")); - - // Check date after change - assert.isNotVisible(datepicker, "date range picker should be closed"); - assert.strictEqual( - fields[0].querySelector("input").value, - "02/16/2017", - "the date should be '02/16/2017'" - ); - assert.strictEqual( - fields[1].querySelector("input").value, - "03/12/2017", - "'the date should be '03/12/2017'" - ); - - // Try to change range with end date - await click(fields[1].querySelector("input")); - datepicker = document.querySelector( - `.daterangepicker[data-name="${fields[1].getAttribute("name")}"]` - ); - - assert.isVisible(datepicker, "date range picker should be opened"); - assert.strictEqual( - datepicker.querySelector(".active.start-date").textContent, - "16", - "start date should be a 16 in date range picker" - ); - assert.strictEqual( - datepicker.querySelector(".active.end-date").textContent, - "12", - "end date should be a 12 in date range picker" - ); - - // Change date - await triggerEvent( - datepicker, - ".drp-calendar.left .available[data-title='r2c1']", - "mousedown" - ); - await triggerEvent( - datepicker, - ".drp-calendar.right .available[data-title='r2c6']", - "mousedown" - ); - await click(datepicker, ".applyBtn"); - - // Check date after change - assert.isNotVisible( - datepickers[datepickers.length - 1], - "date range picker should be closed" - ); - assert.strictEqual( - fields[0].querySelector("input").value, - "02/13/2017", - "the start date should be '02/13/2017'" - ); - assert.strictEqual( - fields[1].querySelector("input").value, - "03/18/2017", - "the end date should be '03/18/2017'" - ); - - // Save - await clickSave(target); - fields = target.querySelectorAll(".o_field_daterange"); - - // Check date after save - assert.strictEqual( - fields[0].querySelector("input").value, - "02/13/2017", - "the start date should be '02/13/2017' after save" - ); - assert.strictEqual( - fields[1].querySelector("input").value, - "03/18/2017", - "the end date should be '03/18/2017' after save" - ); - } - ); - - QUnit.test( - "daterangepicker should disappear on scrolling outside of it", - async function (assert) { - serverData.models.partner.fields.datetime_end = { - string: "Datetime End", - type: "datetime", - }; - serverData.models.partner.records[0].datetime_end = "2017-03-13 00:00:00"; - - await makeView({ - type: "form", - resModel: "partner", - resId: 1, - serverData, - arch: ` -
- - - `, - }); - - await click(target.querySelector(".o_field_daterange[name='datetime'] input")); + await click(target.querySelector("input[data-field=datetime]")); assert.isVisible( - document.querySelector(".daterangepicker[data-name='datetime']"), + document.querySelector(".o_datetime_picker"), "date range picker should be opened" ); await triggerScroll(target, { top: 50 }); - assert.isNotVisible( - document.querySelector(".daterangepicker[data-name='datetime']"), - "date range picker should be closed" + assert.isVisible( + document.querySelector(".o_datetime_picker"), + "date range picker should still be opened" ); } ); - QUnit.test("DateRangeField with label opens datepicker on click", async function (assert) { + QUnit.test("DateRangeField with label opens datepicker on click", async (assert) => { serverData.models.partner.fields.date_end = { string: "Date End", type: "date" }; serverData.models.partner.records[0].date_end = "2017-02-08"; @@ -397,27 +344,22 @@ QUnit.module("Fields", (hooks) => { arch: `