From b5794e89e1ad29e2a86c7ddaf241e3fc24654b5f Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Thu, 4 May 2023 14:20:12 +0000 Subject: [PATCH] [IMP] web: Owl DateTimePicker This commit introduces a date picker OWL component meant to handle the following use-cases: - date picker - date & time picker - date range picker - date & time range picker Basically, this component is the union of the two previous third-party libraries handling these cases: TempusDominus and DateRangePicker. New components introduced: * The main addition of this commit is the `DateTimePicker` itself which handles the display and interactions of the calendar and time pickers. > see @web/core/datetime/datetime_picker * The picker can then be coupled to an input using the `useDateTimePicker` hook. The purpose of this hook is to handle events on a given input element and syncronize its value to a date picker it will spawn in a popover. > see @web/core/datetime/datetime_hook * Lastly, a simple `DateTimeInput` component will render an input and call the hook mentioned above to handle it. This component is effectively replacing the previous DatePicker and DateTimePicker components (note that it does not handle range values). > see @web/core/datetime/datetime_input Another noticeable change of this commit is the definition of daterange fields in views: - Previously, the arch would have to define both fields and bind them via their options, while also adding an arrow between inputs or other forms of connection. - In the new implementation, only the start date field must be declared, and a date range can be spawned by providing an `end_date_field` in its options. Example: ```xml ``` warning Added limitations: - this new way of declaring date ranges means that templates have been revised to declare one field tag instead of two. This means that list views using date ranges have lost the ability to be sorted on their end date fields. > Justification: the current use cases have been reviewed and it has been decided that it was not needed to sort on the end date on the affected list views. > Workaround: drop the date range and declare both fields as simple date pickers (i.e. without the end_date_field option). - all modifiers applied to a field using a date range will be copied and applied to the end date field. There is no way to define modifiers specific to one field or the other. > Justification: there was no use case where one of the two fields needed specific modifiers. > Workaround: same as the previous point: split the range into 2 simple date picker fields. Additional notes: - the widget="daterange" is not mandatory in form views, but is required in list views because only fields with explicit widgets will not be rendered as simple elements. The date range feature will be available as soon as an end_date_field is specified. - as the end date field is not explicitly defined in the view anymore, any modifier depending on it need to have it defined as invisible somewhere in the arch. Task ID: 3121497 Part-of: odoo/odoo#112171 Co-authored-by: Julien Carion Co-authored-by: Julien Mougenot Co-authored-by: Pierre Pulinckx --- .../static/src/tours/hr_holidays_tour.js | 4 +- .../src/web/activity/activity_menu_patch.js | 14 +- .../src/web/activity/activity_menu_patch.xml | 7 +- .../note/static/tests/activity_menu_tests.js | 2 +- .../tests/tours/project_update_tour_tests.js | 6 +- .../static/tests/tours/sale_timesheet_tour.js | 2 +- .../filter_date_value/filter_date_value.js | 6 +- .../filter_date_value/filter_date_value.xml | 11 +- .../global_filters/components/year_picker.js | 97 -- .../tests/dashboard/dashboard_action_test.js | 8 +- .../static/tests/tracking_value_tests.js | 8 +- .../static/src/core/datepicker/datepicker.js | 354 ----- .../static/src/core/datepicker/datepicker.xml | 29 - .../static/src/core/datetime/datetime_hook.js | 393 +++++ .../src/core/datetime/datetime_input.js | 40 + .../src/core/datetime/datetime_input.xml | 13 + .../src/core/datetime/datetime_picker.js | 706 +++++++++ .../src/core/datetime/datetime_picker.scss | 159 ++ .../src/core/datetime/datetime_picker.xml | 152 ++ .../core/datetime/datetime_picker_popover.js | 33 + .../core/datetime/datetime_picker_popover.xml | 14 + .../domain_selector/domain_selector_fields.js | 62 +- .../web/static/src/core/dropdown/dropdown.js | 2 +- .../src/views/fields/date/date_field.js | 79 - .../src/views/fields/date/date_field.xml | 21 - .../views/fields/daterange/daterange_field.js | 193 --- .../fields/daterange/daterange_field.xml | 13 - .../views/fields/datetime/datetime_field.js | 296 +++- .../views/fields/datetime/datetime_field.scss | 14 + .../views/fields/datetime/datetime_field.xml | 74 +- .../fields/properties/properties_field.js | 2 +- .../views/fields/properties/property_value.js | 30 +- .../fields/properties/property_value.xml | 18 +- .../remaining_days/remaining_days_field.js | 51 +- .../remaining_days/remaining_days_field.xml | 6 +- .../kanban/kanban_record_quick_create.js | 4 +- .../web/static/tests/core/datepicker_tests.js | 657 -------- .../core/datetime/datetime_input_tests.js | 484 ++++++ .../core/datetime/datetime_picker_tests.js | 715 +++++++++ .../core/datetime/datetime_test_helpers.js | 216 +++ .../tests/core/domain_selector_tests.js | 31 +- .../web/static/tests/core/dropdown_tests.js | 27 +- .../views/calendar/calendar_view_tests.js | 116 +- .../tests/views/fields/date_field_tests.js | 532 +++---- .../views/fields/daterange_field_tests.js | 1316 +++++++---------- .../views/fields/datetime_field_tests.js | 331 ++--- .../tests/views/fields/domain_field_tests.js | 29 +- .../views/fields/one2many_field_tests.js | 42 +- .../views/fields/properties_field_tests.js | 43 +- .../fields/remaining_days_field_tests.js | 57 +- .../web/static/tests/views/list_view_tests.js | 85 +- .../static/tests/tours/website_event.js | 10 +- 52 files changed, 4341 insertions(+), 3273 deletions(-) delete mode 100644 addons/spreadsheet/static/src/global_filters/components/year_picker.js delete mode 100644 addons/web/static/src/core/datepicker/datepicker.js delete mode 100644 addons/web/static/src/core/datepicker/datepicker.xml create mode 100644 addons/web/static/src/core/datetime/datetime_hook.js create mode 100644 addons/web/static/src/core/datetime/datetime_input.js create mode 100644 addons/web/static/src/core/datetime/datetime_input.xml create mode 100644 addons/web/static/src/core/datetime/datetime_picker.js create mode 100644 addons/web/static/src/core/datetime/datetime_picker.scss create mode 100644 addons/web/static/src/core/datetime/datetime_picker.xml create mode 100644 addons/web/static/src/core/datetime/datetime_picker_popover.js create mode 100644 addons/web/static/src/core/datetime/datetime_picker_popover.xml delete mode 100644 addons/web/static/src/views/fields/date/date_field.js delete mode 100644 addons/web/static/src/views/fields/date/date_field.xml delete mode 100644 addons/web/static/src/views/fields/daterange/daterange_field.js delete mode 100644 addons/web/static/src/views/fields/daterange/daterange_field.xml create mode 100644 addons/web/static/src/views/fields/datetime/datetime_field.scss delete mode 100644 addons/web/static/tests/core/datepicker_tests.js create mode 100644 addons/web/static/tests/core/datetime/datetime_input_tests.js create mode 100644 addons/web/static/tests/core/datetime/datetime_picker_tests.js create mode 100644 addons/web/static/tests/core/datetime/datetime_test_helpers.js 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: `