[IMP] web: editable list view resize

Added a resizing feature to all non-empty editable lists:
- added a handle to each column header, allowing resize
- column width will expand/shrink the table width
- resized columns will reset when reloading the list or sorting columns
- only available when the list contains data

closes odoo/odoo#35975

Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>


Co-authored-by: Gery Debongnie <ged@odoo.com>
This commit is contained in:
Julien Mougenot
2019-08-26 07:49:28 +00:00
co-authored by Gery Debongnie
parent 38910befdf
commit 7812aec9c1
3 changed files with 216 additions and 8 deletions
@@ -99,6 +99,9 @@ ListRenderer.include({
this.currentRow = null;
this.currentFieldIndex = null;
this.allRecordsIds = null; // flat array of records ids used by navigation
this.isResizing = false;
this.hasBeenResized = false;
this.eventListeners = [];
},
/**
* @override
@@ -110,6 +113,18 @@ ListRenderer.include({
}
return this._super();
},
/**
* Overriden to unbind all attached listeners
*
* @override
*/
destroy: function () {
this.eventListeners.forEach(listener => {
const { type, el, callback, options } = listener;
el.removeEventListener(type, callback, options);
});
return this._super.apply(this, arguments);
},
/**
* The list renderer needs to know if it is in the DOM, and to be notified
* when it is attached to the DOM to properly compute column widths.
@@ -511,6 +526,23 @@ ListRenderer.include({
// Private
//--------------------------------------------------------------------------
/**
* Used to bind event listeners so that they can be unbound when the list
* is destroyed.
* There is no reverse method (list._removeEventListener) because there is
* no issue with removing an non-existing listener.
*
* @private
* @param {string} type event name
* @param {EventTarget} el event target
* @param {Function} callback callback function to attach
* @param {Object} options event listener options
*/
_addEventListener: function (type, el, callback, options) {
el.addEventListener(type, callback, options);
this.eventListeners.push({ type, el, callback, options });
},
/**
* Destroy all field widgets corresponding to a record. Useful when we are
* removing a useless row.
@@ -545,12 +577,20 @@ ListRenderer.include({
// widths w.r.t. their label
return;
}
var $thead = this.$('thead');
$thead.find('th').each((index, th) => {
var $th = $(th);
$th.css('width', this.columnWidths ? this.columnWidths[index] : $th.outerWidth() + 'px');
// Freeze table width according to its size in fixed layout
const table = this.el.getElementsByTagName('table')[0];
const thead = table.getElementsByTagName('thead')[0];
table.style.tableLayout = 'fixed';
table.style.width = `${table.offsetWidth}px`;
// Freeze each th width according to their size in auto layout
table.style.tableLayout = 'auto';
[...thead.getElementsByTagName('th')].forEach((th, index) => {
th.style.width = `${this.columnWidths ? this.columnWidths[index] : th.offsetWidth}px`;
});
this.$('table').css('table-layout', 'fixed');
// Finally set the table layout to fixed
table.style.tableLayout = 'fixed';
},
/**
* Returns the width of a column according the 'width' attribute set in the
@@ -970,8 +1010,27 @@ ListRenderer.include({
if (this.addTrashIcon) {
$thead.find('tr').append($('<th>', {class: 'o_list_record_remove_header'}));
}
this.hasBeenResized = false;
return $thead;
},
/**
* Overriden to add a resize handle in editable list column headers.
* Only applies to data column headers
*
* @override
* @private
*/
_renderHeaderCell: function () {
const $th = this._super.apply(this, arguments);
if (this.editable && this._hasVisibleRecords(this.state)) {
const resizeHandle = document.createElement('span');
resizeHandle.classList = 'o_resize';
resizeHandle.onclick = this._onClickResize.bind(this);
resizeHandle.onmousedown = this._onStartResize.bind(this);
$th.append(resizeHandle);
}
return $th;
},
/**
* Editable rows are possibly extended with a trash icon on their right, to
* allow deleting the corresponding record.
@@ -1212,6 +1271,16 @@ ListRenderer.include({
var fieldIndex = Math.max($tr.find('.o_data_cell').not('.o_list_button').index($td), 0);
this._selectCell(rowIndex, fieldIndex, {event: event});
},
/**
* We want to override any default mouse behaviour when clicking on the resize handles
*
* @private
* @param {MouseEvent} ev
*/
_onClickResize: function (ev) {
ev.stopPropagation();
ev.preventDefault();
},
/**
* We need to manually unselect row, because no one else would do it
*/
@@ -1372,10 +1441,100 @@ ListRenderer.include({
* @private
*/
_onSortColumn: function () {
if (this.currentRow === null) {
if (this.currentRow === null && !this.isResizing) {
this._super.apply(this, arguments);
}
},
/**
* Handles the resize feature on the column headers
*
* @private
* @param {MouseEvent} ev
*/
_onStartResize: function (ev) {
// Only triggered by left mouse button
if (ev.which !== 1) {
return;
}
ev.preventDefault();
ev.stopPropagation();
if (!this.hasBeenResized) {
this._freezeColumnWidths();
}
this.isResizing = true;
this.hasBeenResized = true;
const table = this.el.getElementsByTagName('table')[0];
const th = ev.target.closest('th');
const thPosition = [...th.parentNode.children].indexOf(th);
const resizingColumnElements = [...table.getElementsByTagName('tr')]
.filter(tr => tr.children.length === th.parentNode.children.length)
.map(tr => tr.children[thPosition]);
const optionalDropdown = this.el.getElementsByClassName('o_optional_columns')[0];
const initialX = ev.pageX;
const initialWidth = th.offsetWidth;
const initialTableWidth = table.offsetWidth;
const initialDropdownX = optionalDropdown ? optionalDropdown.offsetLeft : null;
const resizeStoppingEvents = [
'keydown',
'mousedown',
'mouseup',
];
// Apply classes to table and selected column
table.classList.add('o_resizing');
resizingColumnElements.forEach(el => el.classList.add('o_column_resizing'));
// Mousemove event : resize header
const resizeHeader = ev => {
ev.preventDefault();
ev.stopPropagation();
const delta = ev.pageX - initialX;
const newWidth = Math.max(10, initialWidth + delta);
const tableDelta = newWidth - initialWidth;
th.style.width = `${newWidth}px`;
table.style.width = `${initialTableWidth + tableDelta}px`;
if (optionalDropdown) {
optionalDropdown.style.left = `${initialDropdownX + tableDelta}px`;
}
};
this._addEventListener('mousemove', window, resizeHeader);
// Mouse or keyboard events : stop resize
const stopResize = ev => {
// Ignores the initial 'left mouse button down' event in order
// to not instantly remove the listener
if (ev.type === 'mousedown' && ev.which === 1) {
return;
}
ev.preventDefault();
ev.stopPropagation();
// We need a small timeout to not trigger a click on column header
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.isResizing = false;
}, 100);
window.removeEventListener('mousemove', resizeHeader);
table.classList.remove('o_resizing');
resizingColumnElements.forEach(el => el.classList.remove('o_column_resizing'));
resizeStoppingEvents.forEach(stoppingEvent => {
window.removeEventListener(stoppingEvent, stopResize);
});
// we remove the focus to make sure that the there is no focus inside
// the tr. If that is the case, there is some css to darken the whole
// thead, and it looks quite weird with the small css hover effect.
document.activeElement.blur();
};
// We have to listen to several events to properly stop the resizing function. Those are:
// - mousedown (e.g. pressing right click)
// - mouseup : logical flow of the resizing feature (drag & drop)
// - keydown : (e.g. pressing 'Alt' + 'Tab' or 'Windows' key)
resizeStoppingEvents.forEach(stoppingEvent => {
this._addEventListener(stoppingEvent, window, stopResize);
});
},
/**
* Unselect the row before adding the optional column to the listview
*
+13
View File
@@ -180,10 +180,23 @@
//--------------------------------------------------------------------------
&.o_editable_list {
&.o_resizing tr > :not(.o_column_resizing) {
opacity: 0.5;
}
&.o_empty_list {
table-layout: fixed;
}
span.o_resize {
bottom: 0;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
width: 10px;
}
.o_list_record_remove_header {
width: 32px;
}
+38 -2
View File
@@ -1472,7 +1472,7 @@ QUnit.module('Views', {
{ field: 'datetime', expected: 146, type: 'Datetime' },
{ field: 'amount', expected: 104, type: 'Monetary' },
];
assert.expect(assertions.length + 1);
assert.expect(8);
this.data.foo.records = [];
var list = await createView({
@@ -1490,6 +1490,7 @@ QUnit.module('Views', {
'</tree>',
});
assert.containsNone(list, 'o_resize', "There shouldn't be any resize handle if no data");
assertions.forEach(a => {
assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
`Field ${a.type} should have a fixed width of ${a.expected} pixels`);
@@ -1509,7 +1510,7 @@ QUnit.module('Views', {
{ field: 'datetime', expected: 146, type: 'Datetime' },
{ field: 'amount', expected: 104, type: 'Monetary' },
];
assert.expect(assertions.length + 1);
assert.expect(8);
var list = await createView({
View: ListView,
@@ -1527,6 +1528,7 @@ QUnit.module('Views', {
groupBy: ['int_field'],
});
assert.containsNone(list, 'o_resize', "There shouldn't be any resize handle if no data");
assertions.forEach(a => {
assert.strictEqual(list.$(`th[data-name="${a.field}"]`)[0].offsetWidth, a.expected,
`Field ${a.type} should have a fixed width of ${a.expected} pixels`);
@@ -6992,6 +6994,40 @@ QUnit.module('Views', {
});
// TODO: write test on:
// - default_get with a field not in view
QUnit.test('editable list: resize column headers', async function (assert) {
assert.expect(2);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top">' +
'<field name="foo"/>' +
'<field name="reference" optional="hide"/>' +
'</tree>',
});
// Target handle
const th = list.el.getElementsByTagName('th')[1];
const optionalDropdown = list.el.getElementsByClassName('o_optional_columns')[0];
const optionalInitialX = optionalDropdown.getBoundingClientRect().x;
const resizeHandle = th.getElementsByClassName('o_resize')[0];
const originalWidth = th.offsetWidth;
const expectedWidth = Math.floor(originalWidth / 2 + resizeHandle.offsetWidth / 2);
const delta = originalWidth - expectedWidth;
await testUtils.dom.dragAndDrop(resizeHandle, th, { mousemoveTarget: window, mouseupTarget: window });
const optionalFinalX = Math.floor(optionalDropdown.getBoundingClientRect().x);
assert.strictEqual(th.offsetWidth, expectedWidth,
// 1px for the cell right border
"header width should be halved (plus half the width of the handle)");
assert.strictEqual(optionalFinalX, optionalInitialX - delta,
"optional columns dropdown should have moved the same amount");
list.destroy();
});
});
});