[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
<field
    name="start_datetime"
    widget="daterange"
    options="{'end_date_field': 'end_datetime'}"
/>
```

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 <span> 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 <juca@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Pierre Pulinckx <pipu@odoo.com>
This commit is contained in:
Julien Mougenot
2023-05-05 13:52:22 +02:00
co-authored by Julien Carion Pierre Pulinckx
parent caf40dfe2d
commit b5794e89e1
52 changed files with 4341 additions and 3273 deletions
@@ -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}`,
@@ -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,
};
@@ -10,9 +10,10 @@
<div class="flex-grow-1">
<div class="d-flex px-2">
<span class="o-mail-ActivityMenu-text flex-grow-1"><strong>Add a note</strong></span>
<DatePicker
date="addingNoteDate"
onDateTimeChanged="date => this.addingNoteDate = date ? date : false"
<DateTimeInput
type="'date'"
value="addingNoteDate"
onChange="(value) => (this.addingNoteDate = value || false)"
placeholder="'Today'"
/>
</div>
@@ -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)");
@@ -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"
@@ -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',
}, {
@@ -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
@@ -13,10 +13,13 @@
</option>
</t>
</select>
<YearPicker
date="date"
onDateTimeChanged.bind="onYearChanged"
placeholder="env._t('Select year...')"
<DateTimeInput
value="date"
format="'yyyy'"
minPrecision="'years'"
type="'date'"
placeholder="'Select year...'"
onChange.bind="onYearChanged"
/>
</div>
</templates>
@@ -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,
};
@@ -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...");
}
);
@@ -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"]
);
@@ -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,
},
};
@@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DatePicker" owl="1">
<div class="o_datepicker" aria-atomic="true" t-att-id="datePickerId" data-target-input="nearest" t-ref="root">
<input type="text"
class="o_datepicker_input o_input datetimepicker-input"
t-att-name="props.name"
t-att-id="props.inputId"
t-att-placeholder="props.placeholder"
t-attf-data-target="#{ '#' + datePickerId }"
t-att-readonly="props.readonly"
t-ref="input"
t-on-change="() => this.onDateChange()"
t-on-input="(ev) => this.props.onInput(ev)"
t-on-click="onInputClick"
t-on-keydown="onInputKeydown"
/>
<span
t-if="props.warn_future and state.warning"
class="fa fa-exclamation-triangle text-danger o_tz_warning o_datepicker_warning"
data-tooltip="This date is on the future. Make sure it is what you expected."
/>
<span class="o_datepicker_button" />
</div>
</t>
</templates>
@@ -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<string, any>) => PromiseLike<DateTimePickerProps>} [pickerProps]
* @property {string | ReturnType<typeof useRef>} [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<typeof useRef<HTMLInputElement>>[]} */
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;
};
@@ -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<DateTimeInputProps>} */
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),
});
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateTimeInput" owl="1">
<input
type="text"
t-ref="start-date"
t-att-id="props.id"
class="o_datetime_input o_input cursor-pointer"
autocomplete="off"
t-att-placeholder="props.placeholder"
/>
</t>
</templates>
@@ -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<DateTimePickerProps>) => string} getTitle
* @property {(date: DateTime, params: Partial<DateTimePickerProps>) => Item[]} getItems
* @property {string} mainTitle
* @property {string} nextTitle
* @property {string} prevTitle
* @property {Record<string, number>} 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<PrecisionLevel, PrecisionInfo>}
*/
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<DateTimePickerProps>} */
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 }));
}
}
}
}
}
@@ -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);
}
}
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateTimePicker.Days" owl="1">
<div class="d-flex gap-3">
<t t-foreach="items" t-as="month" t-key="month.id">
<div
class="o_date_picker d-grid flex-grow-1 rounded overflow-hidden"
t-on-pointerleave="() => (state.hoveredDate = null)"
>
<t t-foreach="month.daysOfWeek" t-as="dayOfWeek" t-key="dayOfWeek[0]">
<div
class="o_day_of_week_cell o_text_sm o_cell_md fw-bolder bg-100 border-bottom border-2 d-flex align-items-center justify-content-center"
t-att-title="dayOfWeek[1]"
>
<div class="text-nowrap overflow-hidden" t-esc="dayOfWeek[0]" />
</div>
</t>
<t t-foreach="month.weeks" t-as="week" t-key="week.number">
<t t-if="!isRange">
<div
class="o_week_number_cell o_center o_cell_md o_text_sm bg-100 fw-bolder"
t-att-class="{ 'border-bottom': !week_last }"
t-esc="week.number"
/>
</t>
<t t-foreach="week.days" t-as="itemInfo" t-key="itemInfo.id">
<t t-set="arInfo" t-value="getActiveRangeInfo(itemInfo)" />
<button
class="o_date_item_cell o_datetime_button o_center o_cell_md btn p-1 border-0 fw-normal"
t-att-class="{
'o_active bg-odoo': !itemInfo.isOutOfRange and arInfo.isActive,
'o_preview_active bg-odoo': !itemInfo.isOutOfRange and arInfo.isPreviewActive,
o_active_start: arInfo.isStart or arInfo.isPreviewStart,
o_active_end: arInfo.isEnd or arInfo.isPreviewEnd,
o_today: itemInfo.includesToday,
o_out_of_range: itemInfo.isOutOfRange or itemInfo.isInvalid,
'border-bottom': !week_last,
}"
t-att-disabled="itemInfo.isInvalid"
t-on-pointerenter="() => (state.hoveredDate = itemInfo.range[0])"
t-on-click="() => this.zoomOrSelect(itemInfo)"
t-esc="itemInfo.label"
/>
</t>
</t>
</div>
</t>
</div>
<div class="d-flex justify-content-around position-relative">
<t t-if="props.type === 'datetime'">
<t t-foreach="state.timeValues" t-as="timeValue" t-key="timeValue_index">
<div
class="o_time_picker d-flex align-items-center justify-content-center gap-1"
>
<t t-call="web.DateTimePicker.Select">
<t t-set="unitIndex" t-value="0" />
<t t-set="unitList" t-value="availableHours" />
</t>
<span>:</span>
<t t-call="web.DateTimePicker.Select">
<t t-set="unitIndex" t-value="1" />
<t t-set="unitList" t-value="availableMinutes" />
</t>
<t t-if="meridiems">
<t t-call="web.DateTimePicker.Select">
<t t-set="unitIndex" t-value="3" />
<t t-set="unitList" t-value="meridiems" />
</t>
</t>
</div>
</t>
</t>
<div class="o_datetime_buttons position-absolute end-0 bottom-0">
<t t-slot="buttons" />
</div>
</div>
</t>
<t t-name="web.DateTimePicker.Grid" owl="1">
<div class="o_date_item_picker d-grid">
<t t-foreach="items" t-as="itemInfo" t-key="itemInfo.id">
<t t-set="arInfo" t-value="getActiveRangeInfo(itemInfo)" />
<button
class="o_date_item_cell o_datetime_button o_center o_cell_lg btn p-1"
t-att-class="{
o_today: itemInfo.includesToday,
'o_active text-bg-odoo': arInfo.isActive,
o_active_start: arInfo.isStart,
o_active_end: arInfo.isEnd,
o_out_of_range: itemInfo.isOutOfRange or itemInfo.isInvalid,
}"
t-att-disabled="itemInfo.isInvalid"
t-on-click="() => this.zoomOrSelect(itemInfo)"
t-esc="itemInfo.label"
/>
</t>
</div>
</t>
<t t-name="web.DateTimePicker.Select" owl="1">
<!-- Requires: { unitIndex: number, unitList: [any, string][], timeValue_index: number } -->
<select
class="o_time_picker_select o_input border-1"
t-model="timeValue[unitIndex]"
t-on-change="() => this.selectTime(timeValue_index)"
>
<t t-foreach="unitList" t-as="unit" t-key="unit[0]">
<option
class="text-center"
t-att-value="unit[0]"
t-esc="unit[1]"
t-att-selected="timeValue[unitIndex] === unit[0]"
/>
</t>
</select>
</t>
<t t-name="web.DateTimePicker" owl="1">
<div
class="o_datetime_picker bg-view d-flex flex-column p-3 gap-2 user-select-none"
t-attf-style="--DateTimePicker__Day-template-columns: {{ isRange ? 7 : 8 }}"
>
<nav class="o_datetime_picker_header btn-group">
<button class="o_previous btn btn-secondary flex-grow-0" t-on-click="previous">
<span class="fa fa-chevron-left" t-att-title="activePrecisionLevel.prevTitle" />
</button>
<button
class="o_zoom_out o_datetime_button btn d-flex align-items-center justify-content-around text-truncate opacity-100"
t-att-disabled="isLastPrecisionLevel"
t-att-title="!isLastPrecisionLevel and activePrecisionLevel.mainTitle"
t-on-click="zoomOut"
>
<t t-foreach="titles" t-as="title" t-key="title">
<h5 class="o_header_part o_text_inherit fw-bold m-0" t-esc="title" />
</t>
</button>
<button class="o_next btn btn-secondary flex-grow-0" t-on-click="next">
<span
class="fa fa-chevron-right"
t-att-title="activePrecisionLevel.nextTitle"
/>
</button>
</nav>
<t t-if="state.precision === 'days'">
<t t-call="web.DateTimePicker.Days" />
</t>
<t t-else="">
<t t-call="web.DateTimePicker.Grid" />
</t>
</div>
</t>
</templates>
@@ -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<DateTimePickerPopoverProps>} */
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());
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateTimePickerPopover" owl="1">
<DateTimePicker t-props="props.pickerProps">
<t t-set-slot="buttons">
<t t-if="props.pickerProps.type === 'datetime' or Array.isArray(props.pickerProps.value)">
<button class="o_apply btn btn-primary btn-sm" t-on-click="props.close">
<i class="fa fa-check" />
</button>
</t>
</t>
</DateTimePicker>
</t>
</templates>
@@ -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<serializeDate>} args
*/
const genericSerializeDate = (type, ...args) =>
type === "date" ? serializeDate(...args) : serializeDateTime(...args);
/**
* @param {"date" | "datetime"} type
* @param {Parameters<deserializeDate>} 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,
@@ -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;
}
@@ -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);
@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateField" owl="1">
<t t-if="props.readonly">
<span t-esc="formattedValue" />
</t>
<t t-else="">
<DatePicker
t-props="props.pickerOptions"
date="date"
inputId="props.id"
placeholder="props.placeholder"
onDateTimeChanged="(date) => this.onDateTimeChanged(date)"
onInput.bind="onDatePickerInput"
onUpdateInput.bind="onUpdateInput"
/>
</t>
</t>
</templates>
@@ -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);
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateRangeField" owl="1">
<t t-if="props.readonly">
<t t-esc="formattedValue" />
</t>
<t t-else="">
<input class="o_input" style="width:100% !important" t-ref="root" type="text" t-att-id="props.id" t-att-value="formattedValue" t-att-placeholder="props.placeholder" t-on-change="onChangeInput" />
</t>
</t>
</templates>
@@ -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<DateTimeFieldProps>} */
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);
@@ -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;
}
}
}
@@ -1,21 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateTimeField" owl="1">
<t t-if="props.readonly">
<span t-esc="formattedValue" />
</t>
<t t-else="">
<DateTimePicker
t-props="props.pickerOptions"
date="props.record.data[props.name]"
inputId="props.id"
placeholder="props.placeholder"
onDateTimeChanged="(datetime) => this.onDateTimeChanged(datetime)"
onInput.bind="onDatePickerInput"
onUpdateInput.bind="onUpdateInput"
/>
</t>
<div class="d-flex gap-2 align-items-center" t-ref="root">
<!-- Primary date -->
<t t-if="props.readonly">
<div class="d-inline-block w-100" t-esc="formatDisplayValue(values[0])" />
</t>
<t t-else="">
<input
t-ref="start-date"
type="text"
t-att-id="props.id"
class="o_input cursor-pointer"
autocomplete="off"
t-att-placeholder="props.placeholder"
t-att-data-field="props.name"
t-on-input="onInput"
/>
<span
t-if="props.warnFuture and isDateInTheFuture(0)"
class="fa fa-exclamation-triangle text-danger"
title="This date is on the future. Make sure it is what you expected."
/>
</t>
<t t-if="showEndDateInput">
<t t-if="emptyField and !props.readonly and !props.required">
<!-- Add date button -->
<button class="o_add_date btn btn-secondary btn-sm w-100" t-on-click="addDate">
<i class="fa fa-plus me-2" />
<t t-if="emptyField === props.name">Add start date</t>
<t t-else="">Add end date</t>
</button>
</t>
<t t-else="">
<!-- Separator -->
<i class="fa fa-long-arrow-right" aria-label="Arrow icon" title="Arrow" />
<!-- Secondary date -->
<t t-if="props.readonly">
<div class="d-inline-block w-100" t-esc="formatDisplayValue(values[1])" />
</t>
<t t-else="">
<input
t-ref="end-date"
type="text"
t-att-id="props.startDateField and props.id"
class="o_input cursor-pointer"
autocomplete="off"
t-att-placeholder="props.placeholder"
t-att-data-field="props.endDateField"
t-on-input="onInput"
/>
<span
t-if="props.warnFuture and isDateInTheFuture(1)"
class="fa fa-exclamation-triangle text-danger"
title="This date is on the future. Make sure it is what you expected."
/>
</t>
</t>
</t>
</div>
</t>
</templates>
@@ -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;
}
@@ -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,
@@ -13,18 +13,12 @@
onChange.bind="onValueChange"
/>
</t>
<t t-elif="props.type === 'datetime'">
<DateTimePicker
inputId="props.id"
date="propertyValue"
onDateTimeChanged.bind="onValueChange"
/>
</t>
<t t-elif="props.type === 'date'">
<DatePicker
inputId="props.id"
date="propertyValue"
onDateTimeChanged.bind="onValueChange"
<t t-elif="['date', 'datetime'].includes(props.type)">
<DateTimeInput
id="props.id"
type="props.type"
value="propertyValue"
onChange.bind="onValueChange"
/>
</t>
<t t-elif="props.type === 'integer'">
@@ -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]);
}
}
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.RemainingDaysField" owl="1">
<t t-if="props.readonly">
<t t-set="days" t-value="diffDays" />
@@ -24,10 +23,7 @@
</div>
</t>
<t t-else="">
<div>
<t t-component="pickerComponent" date="props.record.data[props.name] or false" onDateTimeChanged.bind="onDateTimeChanged" />
</div>
<DateTimeField t-props="props" />
</t>
</t>
</templates>
@@ -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) {
@@ -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<DatePicker>}
*/
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 */ `
<t t-component="props.Picker" t-props="state" onDateTimeChanged.bind="onDateChange" />
`;
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");
});
});
@@ -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`
<DateTimeInput t-props="props" />
<t t-foreach="mainComponentEntries" t-as="comp" t-key="comp[0]">
<t t-component="comp[1].Component" t-props="comp[1].props" />
</t>
`;
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");
});
});
@@ -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"]);
});
});
@@ -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;
@@ -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([]);
});
+14 -13
View File
@@ -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 }) => {
<t t-set-slot="toggler">
Dropdown toggler
</t>
<DateTimePicker onDateTimeChanged="() => {}" date="false"/>
<DateTimeInput />
</Dropdown>
`;
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 === "");
}
);
@@ -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"));
@@ -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) => {
<field name="date" />
</form>`,
});
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: `
<form>
<field name="foo" />
<field name="date" />
</form>`,
});
});
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: '<form><field name="date"/></form>',
});
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: '<form><field name="date"/></form>',
});
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: '<form><field name="date"/></form>',
});
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: '<form><field name="date"/></form>',
});
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: '<form><field name="date"/></form>',
});
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) => {
</form>`,
});
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: `
<form>
<field name="date" options="{ 'datepicker': { 'warn_future': true } }" />
<field name="date" options="{'warn_future': true}" />
</form>`,
});
// 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: `
<form>
<field name="foo" /> <!-- Do not let the date field get the focus in the first place -->
<field name="date" options="{ 'datepicker': { 'warn_future': true } }" />
<field name="date" options="{'warn_future': true}" />
</form>`,
});
@@ -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: '<tree multi_edit="1"><field name="date"/></tree>',
});
await makeView({
serverData,
type: "list",
resModel: "partner",
arch: '<tree multi_edit="1"><field name="date"/></tree>',
});
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: '<form><field name="datetime" widget="date"/></form>',
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: '<form><field name="date"/></form>',
});
$(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: '<form><field name="date"/></form>',
});
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: '<form><field name="date"/></form>',
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";
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,24 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import {
getPickerApplyButton,
getPickerCell,
getTimePickers,
zoomOut,
} from "@web/../tests/core/datetime/datetime_test_helpers";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import {
click,
clickSave,
editInput,
editSelect,
getFixture,
patchTimeZone,
triggerEvent,
triggerEvents,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { registry } from "@web/core/registry";
let serverData;
let target;
@@ -55,7 +62,7 @@ QUnit.module("Fields", (hooks) => {
QUnit.module("DatetimeField");
QUnit.test("DatetimeField in form view", async function (assert) {
QUnit.test("DatetimeField in form view", async (assert) => {
patchTimeZone(120);
await makeView({
@@ -70,56 +77,32 @@ QUnit.module("Fields", (hooks) => {
assert.strictEqual(
target.querySelector(".o_field_datetime input").value,
expectedDateString,
"the datetime should be correctly displayed in readonly"
);
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
expectedDateString,
"the datetime should be correct in edit mode"
"the datetime should be correctly displayed"
);
// datepicker should not open on focus
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
assert.containsNone(target, ".o_datetime_picker");
await click(target, ".o_datepicker_input");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
await click(target, ".o_field_datetime input");
assert.containsOnce(target, ".o_datetime_picker");
// select 22 February at 8:25:35
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")[3]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .day[data-day*='/22/']")
);
await click(document.body.querySelector(".bootstrap-datetimepicker-widget .fa-clock-o"));
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-hour")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .hour")[8]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-minute")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .minute")[5]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-second")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .second")[7]);
// select 22 April 2018 at 8:25
await zoomOut();
await zoomOut();
await click(getPickerCell("2018"));
await click(getPickerCell("Apr"));
await click(getPickerCell("22"));
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await editSelect(hourSelect, null, "8");
await editSelect(minuteSelect, null, "25");
// Close the datepicker
await click(target, ".o_form_view_container");
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed"
);
assert.containsNone(target, ".o_datetime_picker", "datepicker should be closed");
const newExpectedDateString = "04/22/2017 08:25:35";
const newExpectedDateString = "04/22/2018 08:25:00";
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
target.querySelector(".o_field_datetime input").value,
newExpectedDateString,
"the selected date should be displayed in the input"
);
@@ -134,8 +117,8 @@ QUnit.module("Fields", (hooks) => {
});
QUnit.test(
"DatetimeField does not trigger fieldChange before datetime completly picked",
async function (assert) {
"DatetimeField only triggers fieldChange when a day is picked and when an hour/minute is selected",
async (assert) => {
patchTimeZone(120);
serverData.models.partner.onchanges = {
@@ -154,60 +137,38 @@ QUnit.module("Fields", (hooks) => {
},
});
await click(target, ".o_datepicker_input");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
await click(target, ".o_field_datetime input");
// select a date and time
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")[3]
);
await click(
document.body.querySelector(
".bootstrap-datetimepicker-widget .day[data-day*='/22/']"
)
);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .fa-clock-o")
);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-hour")
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .hour")[8]
);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-minute")
);
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .minute")[5]
);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-second")
);
assert.verifySteps([], "should not have done any onchange yet");
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .second")[7]
);
assert.containsOnce(target, ".o_datetime_picker");
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
// select 22 April 2018 at 8:25
await zoomOut();
await zoomOut();
await click(getPickerCell("2018"));
await click(getPickerCell("Apr"));
await click(getPickerCell("22"));
assert.verifySteps([]);
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await editSelect(hourSelect, null, "8");
await editSelect(minuteSelect, null, "25");
assert.verifySteps([]);
// Close the datepicker
await click(target);
assert.containsNone(target, ".o_datetime_picker");
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
"04/22/2017 08:25:35"
target.querySelector(".o_field_datetime input").value,
"04/22/2018 08:25:00"
);
assert.verifySteps(["onchange"], "should have done only one onchange");
assert.verifySteps(["onchange"]);
}
);
QUnit.test("DatetimeField with datetime formatted without second", async function (assert) {
QUnit.test("DatetimeField with datetime formatted without second", async (assert) => {
patchTimeZone(0);
serverData.models.partner.fields.datetime.default = "2017-08-02 12:00:05";
@@ -234,14 +195,13 @@ QUnit.module("Fields", (hooks) => {
assert.strictEqual(
target.querySelector(".o_field_datetime input").value,
expectedDateString,
"the datetime should be correctly displayed in readonly"
"the datetime should be correctly displayed"
);
await click(target, ".o_form_button_cancel");
assert.containsNone(document.body, ".modal", "there should not be a Warning dialog");
assert.containsNone(target, ".modal", "there should not be a Warning dialog");
});
QUnit.test("DatetimeField in editable list view", async function (assert) {
QUnit.test("DatetimeField in editable list view", async (assert) => {
patchTimeZone(120);
await makeView({
@@ -256,69 +216,51 @@ QUnit.module("Fields", (hooks) => {
assert.strictEqual(
cell.textContent,
expectedDateString,
"the datetime should be correctly displayed in readonly"
"the datetime should be correctly displayed"
);
// switch to edit mode
await click(target.querySelector(".o_data_row .o_data_cell"));
assert.containsOnce(
target,
"input.o_datepicker_input",
".o_field_datetime input",
"the view should have a date input for editable mode"
);
assert.strictEqual(
target.querySelector("input.o_datepicker_input"),
target.querySelector(".o_field_datetime input"),
document.activeElement,
"date input should have the focus"
);
assert.strictEqual(
target.querySelector("input.o_datepicker_input").value,
target.querySelector(".o_field_datetime input").value,
expectedDateString,
"the date should be correct in edit mode"
);
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
await click(target, ".o_datepicker_input");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
assert.containsNone(target, ".o_datetime_picker");
await click(target, ".o_field_datetime input");
assert.containsOnce(target, ".o_datetime_picker");
// select 22 February at 8:25:35
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")[3]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .day[data-day*='/22/']")
);
await click(document.body.querySelector(".bootstrap-datetimepicker-widget .fa-clock-o"));
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-hour")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .hour")[8]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-minute")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .minute")[5]);
await click(
document.body.querySelector(".bootstrap-datetimepicker-widget .timepicker-second")
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .second")[7]);
// select 22 April 2018 at 8:25
await zoomOut();
await zoomOut();
await click(getPickerCell("2018"));
await click(getPickerCell("Apr"));
await click(getPickerCell("22"));
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await editSelect(hourSelect, null, "8");
await editSelect(minuteSelect, null, "25");
// Apply changes
await click(getPickerApplyButton());
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be closed"
);
assert.containsNone(target, ".o_datetime_picker", "datepicker should be closed");
const newExpectedDateString = "04/22/2017 08:25:35";
const newExpectedDateString = "04/22/2018 08:25:00";
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
target.querySelector(".o_field_datetime input").value,
newExpectedDateString,
"the selected datetime should be displayed in the input"
"the date should be updated in the input"
);
// save
@@ -331,8 +273,8 @@ QUnit.module("Fields", (hooks) => {
});
QUnit.test(
"multi edition of DatetimeField in list view: edit date in input",
async function (assert) {
"multi edition of DatetimeField in list view: edit date in input [REQUIRE FOCUS]",
async (assert) => {
await makeView({
serverData,
type: "list",
@@ -348,10 +290,12 @@ QUnit.module("Fields", (hooks) => {
await click(rows[0], ".o_data_cell");
assert.containsOnce(target, "input.o_datepicker_input");
await editInput(target, ".o_datepicker_input", "10/02/2019 09:00:00");
assert.containsOnce(target, ".o_field_datetime input");
await editInput(target, ".o_field_datetime input", "10/02/2019 09:00:00");
assert.containsOnce(target, ".modal");
assert.containsOnce(document.body, ".modal");
await click(target.querySelector(".modal .modal-footer .btn-primary"));
assert.strictEqual(
@@ -366,8 +310,8 @@ QUnit.module("Fields", (hooks) => {
);
QUnit.test(
"multi edition of DatetimeField in list view: clear date in input",
async function (assert) {
"multi edition of DatetimeField in list view: clear date in input [REQUIRE FOCUS]",
async (assert) => {
serverData.models.partner.records[1].datetime = "2017-02-08 10:00:00";
await makeView({
@@ -385,10 +329,12 @@ QUnit.module("Fields", (hooks) => {
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_datetime input");
await editInput(target, ".o_field_datetime input", "");
assert.containsOnce(target, ".modal");
assert.containsOnce(document.body, ".modal");
await click(target, ".modal .modal-footer .btn-primary");
assert.strictEqual(
@@ -402,7 +348,7 @@ QUnit.module("Fields", (hooks) => {
}
);
QUnit.test("DatetimeField remove value", async function (assert) {
QUnit.test("DatetimeField remove value", async (assert) => {
assert.expect(4);
patchTimeZone(120);
@@ -425,16 +371,16 @@ QUnit.module("Fields", (hooks) => {
});
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
target.querySelector(".o_field_datetime input").value,
"02/08/2017 12:00:00",
"the date time should be correct in edit mode"
);
const input = target.querySelector(".o_datepicker_input");
const input = target.querySelector(".o_field_datetime input");
input.value = "";
await triggerEvents(input, null, ["input", "change", "focusout"]);
assert.strictEqual(
target.querySelector(".o_datepicker_input").value,
target.querySelector(".o_field_datetime input").value,
"",
"should have an empty input"
);
@@ -449,8 +395,8 @@ QUnit.module("Fields", (hooks) => {
});
QUnit.test(
"DatetimeField with date/datetime widget (with day change)",
async function (assert) {
"DatetimeField with date/datetime widget (with day change) does not care about widget",
async (assert) => {
patchTimeZone(-240);
serverData.models.partner.records[0].p = [2];
@@ -484,16 +430,16 @@ QUnit.module("Fields", (hooks) => {
// switch to form view
await click(target, ".o_field_widget[name='p'] .o_data_cell");
assert.strictEqual(
document.body.querySelector(".modal .o_field_date[name='datetime'] input").value,
"02/07/2017",
target.querySelector(".modal .o_field_date[name='datetime'] input").value,
"02/07/2017 22:00:00",
"the datetime (date widget) should be correctly displayed in form view"
);
}
);
QUnit.test(
"DatetimeField with date/datetime widget (without day change)",
async function (assert) {
"DatetimeField with date/datetime widget (without day change) does not care about widget",
async (assert) => {
patchTimeZone(-240);
serverData.models.partner.records[0].p = [2];
@@ -527,44 +473,14 @@ QUnit.module("Fields", (hooks) => {
// switch to form view
await click(target, ".o_field_widget[name='p'] .o_data_cell");
assert.strictEqual(
document.body.querySelector(".modal .o_field_date[name='datetime'] input").value,
"02/08/2017",
target.querySelector(".modal .o_field_date[name='datetime'] input").value,
"02/08/2017 06:00:00",
"the datetime (date widget) should be correctly displayed in form view"
);
}
);
QUnit.test("datepicker option: daysOfWeekDisabled", async function (assert) {
serverData.models.partner.fields.datetime.default = "2017-08-02 12:00:05";
serverData.models.partner.fields.datetime.required = true;
await makeView({
type: "form",
resModel: "partner",
serverData,
arch: `
<form>
<field name="datetime" options="{'datepicker': { 'daysOfWeekDisabled': [0, 6] }}" />
</form>`,
});
await click(target, ".o_datepicker_input");
for (const el of document.body.querySelectorAll(".day:nth-child(2), .day:last-child")) {
assert.hasClass(el, "disabled", "first and last days must be disabled");
}
// the assertions below could be replaced by a single hasClass classic on the jQuery set using the idea
// All not <=> not Exists. But we want to be sure that the set is non empty. We don't have an helper
// function for that.
for (const el of document.body.querySelectorAll(
".day:not(:nth-child(2)):not(:last-child)"
)) {
assert.doesNotHaveClass(el, "disabled", "other days must stay clickable");
}
});
QUnit.test("datetime field: hit enter should update value", async function (assert) {
QUnit.test("datetime field: hit enter should update value", async (assert) => {
// This test verifies that the field datetime is correctly computed when:
// - we press enter to validate our entry
// - we click outside the field to validate our entry
@@ -609,34 +525,7 @@ QUnit.module("Fields", (hooks) => {
assert.strictEqual(value, datetimeValue);
});
QUnit.test(
"datetime field with date widget: hit enter should update value",
async function (assert) {
/**
* Don't think this test is usefull in the new system.
*/
patchTimeZone(120);
await makeView({
serverData,
type: "form",
resModel: "partner",
arch: '<form><field name="datetime" widget="date"/></form>',
resId: 1,
});
await editInput(target, ".o_field_widget input", "01/08/22");
await triggerEvent(target, ".o_field_widget input", "keydown", { key: "Enter" });
assert.strictEqual(target.querySelector(".o_field_widget input").value, "01/08/2022");
// Click outside the field to check that the field is not changed
await clickSave(target);
assert.strictEqual(target.querySelector(".o_field_widget input").value, "01/08/2022");
}
);
QUnit.test("DateTimeField with label opens datepicker on click", async function (assert) {
QUnit.test("DateTimeField with label opens datepicker on click", async (assert) => {
await makeView({
type: "form",
resModel: "partner",
@@ -650,10 +539,6 @@ 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");
});
});
@@ -15,6 +15,7 @@ import {
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
import { getPickerCell } from "../../core/datetime/datetime_test_helpers";
let serverData;
let target;
@@ -145,26 +146,18 @@ QUnit.module("Fields", (hooks) => {
});
// 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");
// Change the date in the datepicker
await click(target, ".o_datepicker_input");
await click(target, ".o_datetime_input");
// Select a date in the datepicker
await click(
document.body.querySelector(
`.bootstrap-datetimepicker-widget :not(.today)[data-action="selectDay"]`
)
);
await click(getPickerCell("15"));
// Close the datepicker
await click(
document.body.querySelector(
`.bootstrap-datetimepicker-widget a[data-action="close"]`
)
);
await click(target);
await clickDiscard(target);
// Open the datepicker again
await click(target, ".o_datepicker_input");
await click(target, ".o_datetime_input");
}
);
@@ -825,19 +818,19 @@ QUnit.module("Fields", (hooks) => {
assert.verifySteps(["/web/action/load", "get_views", "read", "search_count", "fields_get"]);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.containsOnce(target, ".o_datepicker", "there should be a datepicker");
assert.containsOnce(target, ".o_datetime_input", "there should be a datepicker input");
// Open and close the datepicker
await click(target, ".o_datepicker_input");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
await click(target, ".o_datetime_input");
assert.containsOnce(target, ".o_datetime_picker");
await triggerEvent(window, null, "scroll");
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
assert.containsOnce(target, ".o_datetime_picker");
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
assert.verifySteps([]);
// Manually input a date
rawDomain = `[("date", ">=", "2020-09-09")]`;
await editInput(target, ".o_datepicker_input", "09/09/2020");
await editInput(target, ".o_datetime_input", "09/09/2020");
assert.verifySteps(["search_count"]);
assert.strictEqual(target.querySelector(".o_domain_debug_input").value, rawDomain);
@@ -1,14 +1,15 @@
/** @odoo-module **/
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import {
addRow,
click,
clickCreate,
clickDiscard,
clickSave,
clickM2OHighlightedItem,
clickOpenedDropdownItem,
clickOpenM2ODropdown,
clickOpenedDropdownItem,
clickSave,
dragAndDrop,
editInput,
getFixture,
@@ -22,17 +23,17 @@ import {
triggerEvents,
triggerHotkey,
} from "@web/../tests/helpers/utils";
import BasicModel from "web.BasicModel";
import { browser } from "@web/core/browser/browser";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { getNextTabableElement } from "@web/core/utils/ui";
import { makeView, makeViewInDialog, setupViewRegistries } from "@web/../tests/views/helpers";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { getNextTabableElement } from "@web/core/utils/ui";
import { session } from "@web/session";
import { RelationalModel } from "@web/views/relational_model";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { rpcService } from "@web/core/network/rpc_service";
import BasicModel from "web.BasicModel";
import { getPickerCell } from "../../core/datetime/datetime_test_helpers";
let serverData;
let target;
@@ -4395,19 +4396,8 @@ QUnit.module("Fields", (hooks) => {
assert.strictEqual(td.textContent, "01/25/2017");
await click(td);
await click(target.querySelector(".o_datepicker_input"));
await nextTick();
await click(document.body.querySelector(".bootstrap-datetimepicker-widget .picker-switch"));
await click(
document.body.querySelectorAll(".bootstrap-datetimepicker-widget .picker-switch")[1]
);
await click(
[...document.body.querySelectorAll(".bootstrap-datetimepicker-widget .year")].filter(
(el) => el.textContent === "2017"
)[0]
);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .month")[1]);
await click(document.body.querySelectorAll(".bootstrap-datetimepicker-widget .day")[22]);
await nextTick(); // wait for picker to open
await click(getPickerCell("1").at(0));
await clickSave(target);
assert.verifySteps(["get_views", "read", "read", "onchange", "write", "read", "read"]);
@@ -7003,7 +6993,7 @@ QUnit.module("Fields", (hooks) => {
await click(target, ".o_data_row td:first-child");
assert.containsOnce(target, ".o_selected_row", "should have selected row");
const { keydownEvent } = triggerHotkey("Shift+Tab");
const { keydownEvent } = await triggerHotkey("Shift+Tab");
await nextTick();
assert.containsNone(target, ".o_selected_row", "list should not be in edition");
// We also check the event is not default prevented, to make sure that the
@@ -7462,8 +7452,7 @@ QUnit.module("Fields", (hooks) => {
await addRow(target);
await editInput(target, ".o_data_row .o_field_widget input", "a name");
def = makeDeferred();
await click(target, ".o_datepicker_input");
await editInput(target, ".o_datepicker_input", "04/27/2022 14:08:52");
await editInput(target, ".o_field_datetime .o_input", "04/27/2022 14:08:52");
// resolve the onchange promise
def.resolve();
@@ -13093,7 +13082,7 @@ QUnit.module("Fields", (hooks) => {
if (method === "onchange") {
return {
value: {
line_ids: [[0, 0, {date: "2020-01-01"}]],
line_ids: [[0, 0, { date: "2020-01-01" }]],
},
};
}
@@ -13109,7 +13098,7 @@ QUnit.module("Fields", (hooks) => {
rootType: "record",
activeFields: serverData.models.turlututu.fields,
},
env.services,
env.services
);
await model.load({
@@ -13131,7 +13120,6 @@ QUnit.module("Fields", (hooks) => {
// The one2many orm commands should be parsed correctly and then, the date value is now a Datetime
// and not no longer a string.
const record = model.root.data.line_ids.records[0];
assert.strictEqual(typeof(record.data.date), "object");
assert.strictEqual(typeof record.data.date, "object");
});
});
@@ -5,6 +5,7 @@ import {
clickDiscard,
clickSave,
editInput,
editSelect,
getFixture,
nextTick,
patchWithCleanup,
@@ -16,6 +17,11 @@ import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog
import { browser } from "@web/core/browser/browser";
import { fieldService } from "@web/core/field_service";
import { registry } from "@web/core/registry";
import {
getPickerApplyButton,
getPickerCell,
getTimePickers,
} from "../../core/datetime/datetime_test_helpers";
let serverData;
let target;
@@ -333,15 +339,16 @@ QUnit.module("Fields", (hooks) => {
assert.strictEqual(type.value, "Date & Time", "Should have changed the property type");
// Choosing a date in the date picker should not close the definition popover
await click(target, ".o_field_property_definition_value .o_datepicker_input");
await click(document, ".datepicker-days tr:first-child .day:nth-child(3)");
assert.ok(
document.querySelector(".picker-switch .fa-check"),
"Should not close the definition popover after selecting a date"
);
await click(document, ".picker-switch .fa-check");
assert.ok(
target.querySelector(".o_property_field_popover"),
await click(target, ".o_field_property_definition_value .o_datetime_input");
await click(getPickerCell("3").at(0));
assert.containsOnce(target, ".o_datetime_picker");
await click(getPickerApplyButton());
assert.containsOnce(
target,
".o_property_field_popover",
"Should not close the definition popover after selecting a date"
);
@@ -354,7 +361,7 @@ QUnit.module("Fields", (hooks) => {
"My Datetime",
"Should have updated the property label"
);
const datetimeComponent = field.querySelector(".o_property_field_value .o_datepicker");
const datetimeComponent = field.querySelector(".o_property_field_value .o_datetime_input");
assert.ok(datetimeComponent, "Should have changed the property type");
// Check that the value is reset (because the type changed)
@@ -1173,7 +1180,7 @@ QUnit.module("Fields", (hooks) => {
name: "property_2",
string: "My DateTime",
type: "datetime",
value: "2018-12-31 11:01:01",
value: "2018-12-31 11:05:00",
},
]);
}
@@ -1193,20 +1200,18 @@ QUnit.module("Fields", (hooks) => {
// edit date property
await click(target, ".o_property_field[property-name=property_1] input");
await click(document.body, ".datepicker [data-day='12/31/2018']");
await click(getPickerCell("31").at(0));
assert.equal(target.querySelector("[property-name=property_1] input").value, "12/31/2018");
// edit date time property
await click(target, ".o_property_field[property-name=property_2] input");
await click(document.body, ".datepicker [data-day='12/31/2018']");
await click(document.body, ".picker-switch [data-action=togglePicker]");
await click(document.body, ".timepicker [data-action=incrementHours]");
await click(document.body, ".timepicker [data-action=incrementMinutes]");
await click(document.body, ".timepicker [data-action=incrementSeconds]");
await click(document.body, ".picker-switch [data-action=close]");
await click(getPickerCell("31").at(0));
const [hourSelect, minuteSelect] = getTimePickers().at(0);
await editSelect(hourSelect, null, "12");
await editSelect(minuteSelect, null, "5");
assert.equal(
target.querySelector("[property-name=property_2] input").value,
"12/31/2018 12:01:01"
"12/31/2018 12:05:00"
);
// save
@@ -8,6 +8,7 @@ import {
patchTimeZone,
} from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { getPickerCell } from "../../core/datetime/datetime_test_helpers";
let serverData;
let target;
@@ -116,22 +117,18 @@ QUnit.module("Fields", (hooks) => {
await click(rows[1], ".o_list_record_selector input");
await click(rows[0], ".o_data_cell");
assert.containsOnce(
target,
"input.o_datepicker_input",
"should have date picker input"
);
assert.containsOnce(target, ".o_field_remaining_days input");
await editInput(target, ".o_datepicker_input", "10/10/2017");
await editInput(target, ".o_field_remaining_days input", "10/10/2017");
await click(target);
assert.containsOnce(document.body, ".modal");
assert.containsOnce(target, ".modal");
assert.strictEqual(
document.querySelector(".modal .o_field_widget").textContent,
"In 2 days",
"should have 'In 2 days' value to change"
);
await click(document.body, ".modal .modal-footer .btn-primary");
await click(target, ".modal .modal-footer .btn-primary");
assert.strictEqual(
rows[0].querySelector(".o_data_cell").textContent,
@@ -177,15 +174,11 @@ QUnit.module("Fields", (hooks) => {
await click(rows[1], ".o_list_record_selector input");
await click(rows[0], ".o_data_cell");
assert.containsOnce(
target,
"input.o_datepicker_input",
"should have date picker input"
);
assert.containsOnce(target, ".o_field_remaining_days input");
await editInput(target, ".o_datepicker_input", "blabla");
await editInput(target, ".o_field_remaining_days input", "blabla");
await click(target);
assert.containsNone(document.body, ".modal");
assert.containsNone(target, ".modal");
assert.strictEqual(cells[0].textContent, "Today");
assert.strictEqual(cells[1].textContent, "Tomorrow");
}
@@ -211,16 +204,12 @@ QUnit.module("Fields", (hooks) => {
assert.strictEqual(target.querySelector(".o_field_widget input").value, "10/08/2017");
assert.containsOnce(target, ".o_form_editable");
assert.containsOnce(target, "div.o_field_widget[name='date'] .o_datepicker");
assert.containsOnce(target, "div.o_field_widget[name='date'] input");
await click(target.querySelector(".o_datepicker .o_datepicker_input"));
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
await click(target, ".o_field_remaining_days input");
assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened");
await click(document.body, ".bootstrap-datetimepicker-widget .day[data-day='10/09/2017']");
await click(getPickerCell("9").at(0));
await click(target, ".o_form_button_save");
assert.strictEqual(target.querySelector(".o_field_widget input").value, "10/09/2017");
});
@@ -238,12 +227,9 @@ QUnit.module("Fields", (hooks) => {
</form>`,
});
assert.containsOnce(
target,
".o_form_editable .o_field_widget[name='date'] .o_datepicker"
);
await click(target.querySelector(".o_field_widget[name='date'] .o_datepicker input"));
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
assert.containsOnce(target, ".o_form_editable .o_field_widget[name='date'] input");
await click(target, ".o_field_widget[name='date'] input");
assert.containsOnce(target, ".o_datetime_picker");
}
);
@@ -303,17 +289,12 @@ QUnit.module("Fields", (hooks) => {
target.querySelector(".o_field_widget input").value,
"10/08/2017 11:00:00"
);
assert.containsOnce(target, "div.o_field_widget[name='datetime'] .o_datepicker");
assert.containsOnce(target, "div.o_field_widget[name='datetime'] input");
await click(target.querySelector(".o_datepicker .o_datepicker_input"));
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
await click(target, ".o_field_widget input");
assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened");
await click(document.body, ".bootstrap-datetimepicker-widget .day[data-day='10/09/2017']");
await click(document.body, "a[data-action='close']");
await click(getPickerCell("9").at(0));
await click(target, ".o_form_button_save");
assert.strictEqual(
target.querySelector(".o_field_widget input").value,
@@ -1,5 +1,6 @@
/** @odoo-module **/
import { Component, onWillStart, xml } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { Domain } from "@web/core/domain";
import { errorService } from "@web/core/errors/error_service";
@@ -10,18 +11,19 @@ import { uiService } from "@web/core/ui/ui_service";
import { getNextTabableElement } from "@web/core/utils/ui";
import { session } from "@web/session";
import { FloatField } from "@web/views/fields/float/float_field";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
import { textField } from "@web/views/fields/text/text_field";
import { ListController } from "@web/views/list/list_controller";
import { DynamicRecordList, DynamicGroupList } from "@web/views/relational_model";
import { DynamicGroupList, DynamicRecordList } from "@web/views/relational_model";
import { actionService } from "@web/webclient/actions/action_service";
import { getPickerApplyButton, getPickerCell } from "../core/datetime/datetime_test_helpers";
import { makeFakeLocalizationService, makeFakeUserService } from "../helpers/mock_services";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
import {
addRow,
click,
clickDiscard,
clickOpenedDropdownItem,
clickOpenM2ODropdown,
clickOpenedDropdownItem,
clickSave,
drag,
dragAndDrop,
@@ -63,8 +65,6 @@ import {
import { createWebClient, doAction, loadState } from "../webclient/helpers";
import { makeView, makeViewInDialog, setupViewRegistries } from "./helpers";
import { Component, onWillStart, xml } from "@odoo/owl";
const fieldRegistry = registry.category("fields");
const serviceRegistry = registry.category("services");
@@ -1611,13 +1611,8 @@ QUnit.module("Views", (hooks) => {
await click(target.querySelector(".o_data_cell"));
assert.containsOnce(target, ".o_selected_row");
await click(target, ".o_datepicker input");
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
triggerHotkey("Escape");
await nextTick();
assert.containsOnce(target, ".o_selected_row");
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
await click(target, ".o_field_date input");
assert.containsOnce(target, ".o_datetime_picker");
triggerHotkey("Escape");
await nextTick();
@@ -1639,20 +1634,8 @@ QUnit.module("Views", (hooks) => {
await click(target.querySelector(".o_list_button_add"));
assert.containsOnce(target, ".o_selected_row");
await click(target, ".o_datepicker input");
assert.containsOnce(
document.body,
".bootstrap-datetimepicker-widget",
"datepicker should be opened"
);
await triggerEvent(document.activeElement, null, "keydown", { key: "Escape" });
assert.containsOnce(target, ".o_selected_row", "the row is still in edition");
assert.containsNone(
document.body,
".bootstrap-datetimepicker-widget",
"the datepicker is no longer visible"
);
await click(target, ".o_field_date input");
assert.containsOnce(target, ".o_datetime_picker", "datepicker should be opened");
await triggerEvent(document.activeElement, null, "keydown", { key: "Escape" });
assert.containsNone(target, ".o_selected_row", "the row is no longer in edition");
@@ -7961,13 +7944,11 @@ QUnit.module("Views", (hooks) => {
document.activeElement,
target.querySelector(".o_field_widget[name=date] input")
);
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
assert.containsOnce(target, ".o_datetime_picker");
await click(
document.body,
".bootstrap-datetimepicker-widget [data-action=selectDay][data-day='02/15/2017']"
);
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
await click(getPickerCell("15"));
assert.containsNone(target, ".o_datetime_picker");
assert.strictEqual(
target.querySelector(".o_field_widget[name=date] input").value,
"02/15/2017"
@@ -7976,8 +7957,6 @@ QUnit.module("Views", (hooks) => {
document.activeElement,
target.querySelector(".o_field_widget[name=date] input")
);
assert.strictEqual(document.activeElement.selectionStart, 0);
assert.strictEqual(document.activeElement.selectionEnd, 10);
});
QUnit.test("click on a button in a list view", async function (assert) {
@@ -10705,8 +10684,7 @@ QUnit.module("Views", (hooks) => {
serverData,
arch: `
<tree multi_edit="1">
<field name="date_start" widget="daterange" options="{'related_end_date': 'date_end'}" />
<field name="date_end" widget="daterange" options="{'related_start_date': 'date_start'}"/>
<field name="date_start" widget="daterange" options="{'end_date_field': 'date_end'}" />
</tree>`,
mockRPC(route, args) {
if (args.method === "write") {
@@ -10720,23 +10698,14 @@ QUnit.module("Views", (hooks) => {
await click(target.querySelector(".o_list_record_selector input"));
await click(target.querySelector(".o_data_row .o_data_cell")); // edit first row
await click(target.querySelector(".o_data_row .o_data_cell .o_field_daterange input"));
// change dates via the daterangepicker
const datepicker = document.querySelector(`.daterangepicker[data-name="date_start"]`);
await triggerEvent(
datepicker,
".drp-calendar.left .available[data-title='r3c1']",
"mousedown"
);
await triggerEvent(
datepicker,
".drp-calendar.right .available[data-title='r2c0']",
"mousedown"
);
const applyBtn = datepicker.querySelector(".applyBtn");
assert.notOk(applyBtn.disabled);
await click(getPickerCell("16").at(0));
await click(getPickerCell("12").at(1));
assert.notOk(getPickerApplyButton().disabled);
// Apply the changes
await click(applyBtn);
await click(getPickerApplyButton());
assert.containsOnce(
target,
".modal",
@@ -10785,8 +10754,7 @@ QUnit.module("Views", (hooks) => {
serverData,
arch: `
<tree multi_edit="1">
<field name="date_start" widget="daterange" options="{'related_end_date': 'date_end'}" />
<field name="date_end" widget="daterange" options="{'related_start_date': 'date_start'}"/>
<field name="date_start" widget="daterange" options="{'end_date_field': 'date_end'}" />
</tree>`,
mockRPC(route, args) {
if (args.method === "write") {
@@ -10802,9 +10770,11 @@ QUnit.module("Views", (hooks) => {
// Change the date in the first datetime
await editInput(
target,
".o_data_row .o_data_cell .o_field_daterange[name='date_start'] input",
".o_data_row .o_data_cell .o_field_daterange[name='date_start'] input[data-field='date_start']",
"2021-04-01 11:00:00"
);
triggerHotkey("Enter");
await nextTick();
assert.containsOnce(
target,
".modal",
@@ -17867,8 +17837,8 @@ QUnit.module("Views", (hooks) => {
assert.containsN(target, ".o_field_cell.o_date_cell", 3);
await click(target.querySelector(".o_field_cell.o_date_cell"));
await click(target, ".o_datepicker input");
await click(document.querySelector(".datepicker-days td[data-day='12/19/2022']"));
await click(target, ".o_field_date input");
await click(getPickerCell("19"));
await clickSave(target);
assert.strictEqual(
@@ -17926,9 +17896,8 @@ QUnit.module("Views", (hooks) => {
assert.containsN(target, ".o_field_cell.o_datetime_cell", 3);
await click(target.querySelector(".o_field_cell.o_datetime_cell"));
await click(target, ".o_datepicker input");
await click(document.querySelector(".datepicker-days td[data-day='12/19/2022']"));
await click(document, ".bootstrap-datetimepicker-widget a[data-action='close']");
await click(target, ".o_field_datetime input");
await click(getPickerCell("19"));
await clickSave(target);
assert.strictEqual(
@@ -26,14 +26,10 @@
trigger: '.modal-dialog div[name=date_begin]',
content: _t("Open date range picker. Pick a Start date for your event"),
run: function () {
$('input[id="date_begin_0"]').val('09/30/2020 08:00:00').change();
$('input[id="date_end_0"]').val('10/02/2020 23:00:00').change();
$('input[id="date_begin_0"]').click();
$('input[data-field="date_begin"]').val('09/30/2020 08:00:00').change();
$('input[data-field="date_end"]').val('10/02/2020 23:00:00').change();
$('input[data-field="date_begin"]').click();
}
}, {
content: _t("Apply change."),
trigger: '.daterangepicker[data-name=date_begin] .applyBtn',
in_modal: false,
}, {
trigger: '.modal-footer button.btn-primary',
extra_trigger: '.modal-dialog input[type=text][value!=""]',