[ADD] web: Owl Dialog

New Owl component: Dialog. It does not rely on Bootstrap but behaves the same
and does not conflict with legacy dialogs.
To call this component, you need to define it in the XML parent template instead
of calling it in the JS file, and put a flag in the parent state to toggle the dialog.
Old dialog file has also been slightly updated to ensure compatibility between both
versions.

This commit also updates a list view test which did not wait for click events to
properly trigger and would cause the new dialog system to crash.
This commit is contained in:
Julien Mougenot
2020-01-06 14:37:16 +00:00
parent ab37648cb1
commit 34bbdd88c0
9 changed files with 617 additions and 18 deletions
+7
View File
@@ -4,6 +4,7 @@ odoo.define('web.Dialog', function (require) {
var core = require('web.core');
var dom = require('web.dom');
var Widget = require('web.Widget');
const OwlDialog = require('web.OwlDialog');
var QWeb = core.qweb;
var _t = core._t;
@@ -205,6 +206,9 @@ var Dialog = Widget.extend({
if (options && options.shouldFocusButtons) {
self._onFocusControlButton();
}
// Notifies OwlDialog to adjust focus/active properties on owl dialogs
OwlDialog.display(self);
});
return self;
@@ -236,6 +240,9 @@ var Dialog = Widget.extend({
return;
}
// Notifies OwlDialog to adjust focus/active properties on owl dialogs
OwlDialog.hide(this);
// Triggers the onForceClose event if the callback is defined
if (this.onForceClose) {
this.onForceClose();
+259
View File
@@ -0,0 +1,259 @@
odoo.define('web.OwlDialog', function (require) {
"use strict";
const { useExternalListener } = require('web.custom_hooks');
const { Component, hooks, misc } = owl;
const { Portal } = misc;
const { useRef } = hooks;
const SIZE_CLASSES = {
'extra-large': 'modal-xl',
'large': 'modal-lg',
'small': 'modal-sm',
};
/**
* Dialog (owl version)
*
* Represents a bootstrap-styled dialog handled with pure JS. Its implementation
* is roughly the same as the legacy dialog, the only exception being the buttons.
* @extends Component
**/
class Dialog extends Component {
/**
* @param {Object} [props]
* @param {(boolean|string)} [props.backdrop='static'] The kind of modal backdrop
* to use (see Bootstrap documentation).
* @param {string} [props.contentClass] Class to add to the dialog
* @param {boolean} [props.fullscreen=false] Whether the dialog should be
* open in fullscreen mode (the main usecase is mobile).
* @param {boolean} [props.renderFooter=true] Whether the dialog footer
* should be rendered.
* @param {boolean} [props.renderHeader=true] Whether the dialog header
* should be rendered.
* @param {string} [props.size='large'] 'extra-large', 'large', 'medium'
* or 'small'.
* @param {string} [props.subtitle='']
* @param {string} [props.title='Odoo']
* @param {boolean} [props.technical=true] If set to false, the modal will have
* the standard frontend style (use this for non-editor frontend features).
*/
constructor() {
super(...arguments);
this.modalRef = useRef('modal');
this.footerRef = useRef('modal-footer');
useExternalListener(window, 'keydown', this._onKeydown);
}
mounted() {
this.constructor.display(this);
this.env.bus.on('close_dialogs', this, this._close);
if (this.props.renderFooter) {
// Set up main button : will first look for an element with the
// 'btn-primary' class, then a 'btn' class, then the first button
// element.
let mainButton = this.footerRef.el.querySelector('.btn.btn-primary');
if (!mainButton) {
mainButton = this.footerRef.el.querySelector('.btn');
}
if (!mainButton) {
mainButton = this.footerRef.el.querySelector('button');
}
if (mainButton) {
this.mainButton = mainButton;
this.mainButton.addEventListener('keydown', this._onMainButtonKeydown.bind(this));
this.mainButton.focus();
}
}
this._removeTooltips();
}
async willUnmount() {
this.env.bus.off('close_dialogs', this, this._close);
this._removeTooltips();
this.constructor.hide(this);
}
//--------------------------------------------------------------------------
// Getters
//--------------------------------------------------------------------------
/**
* @returns {string}
*/
get size() {
return SIZE_CLASSES[this.props.size];
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Send an event signaling that the dialog must be closed.
* @private
*/
_close() {
this.trigger('dialog_closed');
}
/**
* Remove any existing tooltip present in the DOM.
* @private
*/
_removeTooltips() {
for (const tooltip of document.querySelectorAll('.tooltip')) {
tooltip.remove(); // remove open tooltip if any to prevent them staying when modal is opened
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onBackdropClick() {
if (this.props.backdrop === 'static') {
if (this.mainButton) {
this.mainButton.focus();
}
} else {
this._close();
}
}
/**
* @private
*/
_onFocus() {
if (this.mainButton) {
this.mainButton.focus();
}
}
/**
* Manage the TAB key on the main button. If the focus is on a primary
* button and the user tries to tab to go to the next button : a tooltip
* will be displayed.
* @private
* @param {KeyboardEvent} ev
*/
_onMainButtonKeydown(ev) {
if (ev.key === 'Tab' && !ev.shiftKey) {
ev.preventDefault();
$(this.mainButton)
.tooltip({
delay: { show: 200, hide: 0 },
title: () => this.env.qweb.render('DialogButton.tooltip', {
title: this.mainButton.innerText.toUpperCase(),
}),
trigger: 'manual',
})
.tooltip('show');
}
}
/**
* @private
* @param {KeyboardEvent} ev
*/
_onKeydown(ev) {
if (
ev.key === 'Escape' &&
!['INPUT', 'TEXTAREA'].includes(ev.target.tagName) &&
this.constructor.displayed[this.constructor.displayed.length - 1] === this
) {
ev.preventDefault();
ev.stopImmediatePropagation();
ev.stopPropagation();
this._close();
}
}
//--------------------------------------------------------------------------
// Static
//--------------------------------------------------------------------------
/**
* Push the given dialog at the end of the displayed list then set it as
* active and all the others as passive.
* @param {(LegacyDialog|OwlDialog)} dialog
*/
static display(dialog) {
// Deactivate previous dialog
const activeDialogEl = document.querySelector('.modal.o_active_modal');
if (activeDialogEl) {
activeDialogEl.classList.remove('o_active_modal');
}
// Push dialog
this.displayed.push(dialog);
// Add active class
const modalEl = dialog instanceof this ?
// Owl dialog
dialog.modalRef.el :
// Legacy dialog
dialog.$modal[0];
modalEl.classList.add('o_active_modal');
// Update body class
document.body.classList.add('modal-open');
}
/**
* Set the given displayed dialog as passive and the last added displayed dialog
* as active, then remove it from the displayed list.
* @param {(LegacyDialog|OwlDialog)} dialog
*/
static hide(dialog) {
// Remove given dialog from the list
this.displayed.splice(this.displayed.indexOf(dialog), 1);
// Activate last dialog and update body class
const lastDialog = this.displayed[this.displayed.length - 1];
if (lastDialog) {
lastDialog.el.focus();
const modalEl = lastDialog instanceof this ?
// Owl dialog
lastDialog.modalRef.el :
// Legacy dialog
lastDialog.$modal[0];
modalEl.classList.add('o_active_modal');
} else {
document.body.classList.remove('modal-open');
}
}
}
Dialog.displayed = [];
Dialog.components = { Portal };
Dialog.defaultProps = {
backdrop: 'static',
renderFooter: true,
renderHeader: true,
size: 'large',
technical: true,
title: "Odoo",
};
Dialog.props = {
backdrop: { validate: b => ['static', true, false].includes(b), optional: 1 },
contentClass: { type: String, optional: 1 },
fullscreen: { type: Boolean, optional: 1 },
renderFooter: { type: Boolean, optional: 1 },
renderHeader: { type: Boolean, optional: 1 },
size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s), optional: 1 },
subtitle: { type: String, optional: 1 },
technical: { type: Boolean, optional: 1 },
title: { type: String, optional: 1 },
};
Dialog.template = 'OwlDialog';
return Dialog;
});
+22 -4
View File
@@ -54,10 +54,9 @@
footer {
> :not(:first-child) { margin-left: .25rem; }
> :not(:last-child) { margin-right: .25rem; }
}
button {
margin-bottom: .5rem;
button {
margin-bottom: .5rem;
}
}
}
}
@@ -107,9 +106,28 @@
}
}
.modal:not(.o_active_modal) {
z-index: 1030;
}
.o_dialog {
> .modal {
display: block;
pointer-events: none;
}
.modal-content {
pointer-events: auto;
}
}
body.modal-open {
// Allow vertical scrolling in modals on iOS
-webkit-overflow-scrolling: touch;
.modal {
@include o-scrollbar-overlay;
}
}
// Temporary fix for modals which are not instantiated thanks to the Dialog
+7
View File
@@ -385,6 +385,13 @@
}
}
// Scrollbar doesn't overlap with content
@mixin o-scrollbar-overlay {
overflow-y: auto;
overflow-y: overlay;
-ms-overflow-style: -ms-autohiding-scrollbar;
}
%o-nocontent-init-image {
content: "";
display: block;
+48
View File
@@ -1,6 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Owl Templates -->
<t t-name="DialogButton.tooltip" owl="1">
<div class="oe_tooltip_string" role="tooltip">
<div class="tooltip-inner">
Hit ENTER to <t t-esc="title"/>
</div>
</div>
</t>
<t t-name="OwlDialog" owl="1">
<Portal target="'body'">
<div class="o_dialog" t-on-focus="_onFocus">
<div t-if="props.backdrop" class="modal-backdrop show" t-on-click="_onBackdropClick"/>
<div role="dialog" class="modal"
tabindex="-1"
t-att-class="{ o_technical_modal: props.technical, o_modal_full: props.fullscreen }"
t-ref="modal"
t-on-close_dialog.stop="_close"
>
<div class="modal-dialog" t-att-class="size">
<div class="modal-content" t-att-class="props.contentClass">
<header t-if="props.renderHeader" class="modal-header">
<h4 class="modal-title">
<t t-esc="props.title"/>
<span t-if="props.subtitle" class="o_subtitle text-muted small" t-esc="props.subtitle"/>
</h4>
<button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="_close">×</button>
</header>
<main class="modal-body">
<t t-slot="default"/>
</main>
<footer t-if="props.renderFooter" class="modal-footer" t-ref="modal-footer">
<t t-slot="buttons">
<button class="btn btn-primary" t-on-click.prevent="_close">
Ok
</button>
</t>
</footer>
</div>
</div>
</div>
</div>
</Portal>
</t>
<!-- Legacy Templates -->
<div t-name="EmptyComponent"/>
<div t-name="Loading" class="o_loading"/>
+18 -10
View File
@@ -3,17 +3,25 @@
<!-- These templates are accessible in backend and frontend -->
<div role="dialog" t-name="Dialog" t-attf-class="modal#{technical ? ' o_technical_modal' : ''}#{fullscreen ? ' o_modal_full': ''}" tabindex="-1" data-backdrop="static" t-att-id="_.uniqueId('modal_')" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<header t-if="renderHeader" class="modal-header">
<h4 class="modal-title"><t t-raw="title"/><span class="o_subtitle text-muted small"><t t-esc="subtitle"/></span></h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" tabindex="-1">×</button>
</header>
<main class="modal-body"/>
<footer t-if="renderFooter" class="modal-footer"/>
<t t-name="Dialog">
<div role="dialog"
t-attf-class="modal o_legacy_dialog #{ technical ? ' o_technical_modal' : '' } #{ fullscreen ? ' o_modal_full': '' }"
tabindex="-1"
data-backdrop="static"
t-att-id="_.uniqueId('modal_')"
aria-hidden="true"
>
<div class="modal-dialog">
<div class="modal-content">
<header t-if="renderHeader" class="modal-header">
<h4 class="modal-title"><t t-raw="title"/><span class="o_subtitle text-muted small" t-esc="subtitle"/></h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" tabindex="-1">×</button>
</header>
<main class="modal-body"/>
<footer t-if="renderFooter" class="modal-footer"/>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,250 @@
odoo.define('web.owl_dialog_tests', function (require) {
"use strict";
const LegacyDialog = require('web.Dialog');
const makeTestEnvironment = require('web.test_env');
const Dialog = require('web.OwlDialog');
const testUtils = require('web.test_utils');
const { Component, tags, useState } = owl;
const EscapeKey = { key: 'Escape', keyCode: 27, which: 27 };
const { xml } = tags;
QUnit.module('core', {}, function () {
QUnit.module('OwlDialog');
QUnit.test("Rendering of all props", async function (assert) {
assert.expect(26);
class SubComponent extends Component {
// Handlers
_onClick() {
assert.step('subcomponent_clicked');
}
}
SubComponent.template = xml`<div class="o_subcomponent" t-esc="props.text" t-on-click="_onClick"/>`;
class Parent extends Component {
constructor() {
super(...arguments);
this.state = useState({ textContent: "sup" });
}
// Handlers
_onButtonClicked(ev) {
assert.step('button_clicked');
}
_onDialogClosed() {
assert.step('dialog_closed');
}
}
Parent.components = { Dialog, SubComponent };
Parent.env = makeTestEnvironment();
Parent.template = xml`
<Dialog
backdrop="state.backdrop"
contentClass="state.contentClass"
fullscreen="state.fullscreen"
renderFooter="state.renderFooter"
renderHeader="state.renderHeader"
size="state.size"
subtitle="state.subtitle"
technical="state.technical"
title="state.title"
t-on-dialog_closed="_onDialogClosed"
>
<SubComponent text="state.textContent"/>
<t t-set="buttons">
<button class="btn btn-primary" t-on-click="_onButtonClicked">The Button</button>
</t>
</Dialog>`;
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
const dialog = document.querySelector('.o_dialog');
// Helper function
async function changeProps(key, value) {
parent.state[key] = value;
await testUtils.nextTick();
}
// Basic layout with default properties
assert.containsOnce(dialog, '.modal-backdrop.show');
assert.containsOnce(dialog, '.modal.o_technical_modal');
assert.hasClass(dialog.querySelector('.modal .modal-dialog'), 'modal-lg');
assert.containsOnce(dialog, '.modal-header > button.close');
assert.containsOnce(dialog, '.modal-footer > button.btn.btn-primary');
assert.strictEqual(dialog.querySelector('.modal-body').innerText.trim(), "sup",
"Subcomponent should match with its given text");
// Backdrop (default: 'static')
// Static backdrop click should focus first button
// => we need to reset that property
await testUtils.dom.click(dialog.querySelector('.modal-backdrop'));
assert.strictEqual(document.activeElement, dialog.querySelector('.btn-primary'),
"Button should be focused when clicking on backdrop");
await changeProps('backdrop', false);
assert.containsNone(document.body, '.modal-backdrop');
await changeProps('backdrop', true);
await testUtils.dom.click(dialog.querySelector('.modal-backdrop'));
// Dialog class (default: '')
await changeProps('contentClass', 'my_dialog_class');
assert.hasClass(dialog.querySelector('.modal-content'), 'my_dialog_class');
// Full screen (default: false)
assert.doesNotHaveClass(dialog.querySelector('.modal'), 'o_modal_full');
await changeProps('fullscreen', true);
assert.hasClass(dialog.querySelector('.modal'), 'o_modal_full');
// Size class (default: 'large')
await changeProps('size', 'extra-large');
assert.strictEqual(dialog.querySelector('.modal-dialog').className, 'modal-dialog modal-xl',
"Modal should have taken the class modal-xl");
await changeProps('size', 'medium');
assert.strictEqual(dialog.querySelector('.modal-dialog').className, 'modal-dialog',
"Modal should not have any additionnal class with 'medium'");
await changeProps('size', 'small');
assert.strictEqual(dialog.querySelector('.modal-dialog').className, 'modal-dialog modal-sm',
"Modal should have taken the class modal-sm");
// Subtitle (default: '')
await changeProps('subtitle', "The Subtitle");
assert.strictEqual(dialog.querySelector('span.o_subtitle').innerText.trim(), "The Subtitle",
"Subtitle should match with its given text");
// Technical (default: true)
assert.hasClass(dialog.querySelector('.modal'), 'o_technical_modal');
await changeProps('technical', false);
assert.doesNotHaveClass(dialog.querySelector('.modal'), 'o_technical_modal');
// Title (default: 'Odoo')
assert.strictEqual(dialog.querySelector('h4.modal-title').innerText.trim(), "Odoo" + "The Subtitle",
"Title should match with its default text");
await changeProps('title', "The Title");
assert.strictEqual(dialog.querySelector('h4.modal-title').innerText.trim(), "The Title" + "The Subtitle",
"Title should match with its given text");
// Reactivity of buttons
await testUtils.dom.click(dialog.querySelector('.modal-footer .btn-primary'));
// Render footer (default: true)
await changeProps('renderFooter', false);
assert.containsNone(dialog, '.modal-footer');
// Render header (default: true)
await changeProps('renderHeader', false);
assert.containsNone(dialog, '.header');
// Reactivity of subcomponents
await changeProps('textContent', "wassup");
assert.strictEqual(dialog.querySelector('.o_subcomponent').innerText.trim(), "wassup",
"Subcomponent should match with its given text");
await testUtils.dom.click(dialog.querySelector('.o_subcomponent'));
assert.verifySteps(['dialog_closed', 'button_clicked', 'subcomponent_clicked']);
parent.destroy();
});
QUnit.test("Interactions between multiple dialogs", async function (assert) {
assert.expect(22);
class Parent extends Component {
constructor() {
super(...arguments);
this.dialogIds = useState([]);
}
// Handlers
_onDialogClosed(id) {
assert.step(`dialog_${id}_closed`);
this.dialogIds.splice(this.dialogIds.findIndex(d => d === id), 1);
}
}
Parent.components = { Dialog };
Parent.env = makeTestEnvironment();
Parent.template = xml`
<div>
<Dialog t-foreach="dialogIds" t-as="dialogId" t-key="dialogId"
contentClass="'dialog_' + dialogId"
t-on-dialog_closed="_onDialogClosed(dialogId)"
/>
</div>`;
const parent = new Parent();
await parent.mount(testUtils.prepareTarget());
// Dialog 1 : Owl
parent.dialogIds.push(1);
await testUtils.nextTick();
// Dialog 2 : Legacy
new LegacyDialog(null, {}).open();
await testUtils.nextTick();
// Dialog 3 : Legacy
new LegacyDialog(null, {}).open();
await testUtils.nextTick();
// Dialog 4 : Owl
parent.dialogIds.push(4);
await testUtils.nextTick();
// Dialog 5 : Owl
parent.dialogIds.push(5);
await testUtils.nextTick();
let modals = document.querySelectorAll('.modal');
assert.ok(modals[modals.length - 1].classList.contains('o_active_modal'),
"last dialog should have the active class");
assert.notOk(modals[modals.length - 1].classList.contains('o_legacy_dialog'),
"active dialog should not have the legacy class");
assert.containsN(document.body, '.o_dialog', 3);
assert.containsN(document.body, '.o_legacy_dialog', 2);
// Reactivity with owl dialogs
await testUtils.dom.triggerEvent(modals[modals.length - 1], 'keydown', EscapeKey); // Press Escape
modals = document.querySelectorAll('.modal');
assert.ok(modals[modals.length - 1].classList.contains('o_active_modal'),
"last dialog should have the active class");
assert.notOk(modals[modals.length - 1].classList.contains('o_legacy_dialog'),
"active dialog should not have the legacy class");
assert.containsN(document.body, '.o_dialog', 2);
assert.containsN(document.body, '.o_legacy_dialog', 2);
await testUtils.dom.click(modals[modals.length - 1].querySelector('.btn.btn-primary')); // Click on 'Ok' button
modals = document.querySelectorAll('.modal');
assert.containsOnce(document.body, '.modal.o_legacy_dialog.o_active_modal',
"active dialog should have the legacy class");
assert.containsOnce(document.body, '.o_dialog');
assert.containsN(document.body, '.o_legacy_dialog', 2);
// Reactivity with legacy dialogs
await testUtils.dom.triggerEvent(modals[modals.length - 1], 'keydown', EscapeKey);
modals = document.querySelectorAll('.modal');
assert.containsOnce(document.body, '.modal.o_legacy_dialog.o_active_modal',
"active dialog should have the legacy class");
assert.containsOnce(document.body, '.o_dialog');
assert.containsOnce(document.body, '.o_legacy_dialog');
await testUtils.dom.click(modals[modals.length - 1].querySelector('.close'));
modals = document.querySelectorAll('.modal');
assert.ok(modals[modals.length - 1].classList.contains('o_active_modal'),
"last dialog should have the active class");
assert.notOk(modals[modals.length - 1].classList.contains('o_legacy_dialog'),
"active dialog should not have the legacy class");
assert.containsOnce(document.body, '.o_dialog');
assert.containsNone(document.body, '.o_legacy_dialog');
parent.unmount();
assert.containsNone(document.body, '.modal');
// dialog 1 is closed through the removal of its parent => no callback
assert.verifySteps(['dialog_5_closed', 'dialog_4_closed']);
parent.destroy();
});
});
});
+4 -4
View File
@@ -2462,18 +2462,18 @@ QUnit.module('Views', {
assert.isNotVisible(list.sidebar.$el, 'sidebar should be invisible');
assert.containsN(list, 'tbody td.o_list_record_selector', 4, "should have 4 records");
testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
await testUtils.dom.click(list.$('tbody td.o_list_record_selector:first input'));
assert.isVisible(list.sidebar.$el, 'sidebar should be visible');
assert.verifySteps(['/web/dataset/search_read']);
testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
await testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
await testUtils.dom.click(list.sidebar.$('a:contains(Archive)'));
assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
testUtils.dom.click($('.modal-footer .btn-secondary'));
await testUtils.dom.click($('.modal-footer .btn-secondary'));
assert.containsN(list, 'tbody td.o_list_record_selector', 4, "still should have 4 records");
testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
await testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
await testUtils.dom.click(list.sidebar.$('a:contains(Archive)'));
assert.strictEqual($('.modal').length, 1, 'a confirm modal should be displayed');
await testUtils.dom.click($('.modal-footer .btn-primary'));
+2
View File
@@ -138,6 +138,7 @@
<script type="text/javascript" src="/web/static/src/js/core/collections.js"/>
<script type="text/javascript" src="/web/static/src/js/core/concurrency.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/dialog.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/owl_dialog.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/dom.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/local_storage.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/mixins.js"></script>
@@ -705,6 +706,7 @@
<script type="text/javascript" src="/web/static/tests/core/util_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/widget_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/dialog_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/owl_dialog_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/dom_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/chrome/action_manager_tests.js"></script>