[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:
co-authored by
Gery Debongnie
parent
38910befdf
commit
7812aec9c1
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user