[IMP] web: list: select range of records with shift

Before, in the list view, the user could not select a record range easily. He had to
check/uncheck manually each checkbox in the range.

Now, the user can select a range of records in list view by hitting its first record
and and its last record while holding SHIFT for the second.

It's also possible to do it with the keyboard:
- pressing <SPACE> to select a record and <SHIFT+SPACE> to select a range of records.
- pressing <SHIFT+ARROW> to extend to current checkbox

task-2039894

Part-of: odoo/odoo#99050
This commit is contained in:
Dardenne Florent (dafl)
2022-11-30 15:06:00 +01:00
committed by fdardenne
parent 8414b797c3
commit edd296da29
3 changed files with 240 additions and 8 deletions
@@ -174,6 +174,20 @@ export class ListRenderer extends Component {
this.columnWidths = null;
this.freezeColumnWidths();
});
useExternalListener(window, "keydown", (ev) => {
this.shiftKeyMode = ev.shiftKey;
});
useExternalListener(window, "keyup", (ev) => {
this.shiftKeyMode = ev.shiftKey;
const hotkey = getActiveHotkey(ev);
if (hotkey === "shift") {
this.shiftKeyedRecord = undefined;
}
});
useExternalListener(window, "blur", (ev) => {
this.shiftKeyMode = false;
});
}
getActiveColumns(list) {
@@ -1059,6 +1073,38 @@ export class ListRenderer extends Component {
return null;
}
expandCheckboxes(record, direction) {
const { records } = this.props.list;
const recordIndex = records.indexOf(record);
const shiftKeyedRecordIndex = records.indexOf(this.shiftKeyedRecord);
let nextRecord;
let isExpanding;
switch (direction) {
case "up":
if (recordIndex <= 0) {
return false;
}
nextRecord = records[recordIndex - 1];
isExpanding = shiftKeyedRecordIndex > recordIndex - 1;
break;
case "down":
if (recordIndex === records.length - 1) {
return false;
}
nextRecord = records[recordIndex + 1];
isExpanding = shiftKeyedRecordIndex < recordIndex + 1;
break;
}
if (isExpanding) {
nextRecord.toggleSelection(this.shiftKeyedRecord.selected);
} else {
record.toggleSelection(false);
}
return true;
}
applyCellKeydownMultiEditMode(hotkey, cell, group, record) {
const { list } = this.props;
const row = cell.parentElement;
@@ -1387,6 +1433,25 @@ export class ListRenderer extends Component {
toFocus = buttons[index - 1] || currentButton;
}
break;
case "shift+arrowdown": {
if (this.expandCheckboxes(record, "down")) {
toFocus = this.findFocusFutureCell(cell, cellIsInGroupRow, "down");
}
break;
}
case "shift+arrowup": {
if (this.expandCheckboxes(record, "up")) {
toFocus = this.findFocusFutureCell(cell, cellIsInGroupRow, "up");
}
break;
}
case "shift+space":
this.toggleRecordSelection(record);
toFocus = getElementToFocus(cell);
break;
case "shift":
this.shiftKeyedRecord = record;
break;
case "enter":
if (!group && !record) {
return false;
@@ -1518,10 +1583,28 @@ export class ListRenderer extends Component {
}
toggleRecordSelection(record) {
record.toggleSelection();
if (this.shiftKeyMode && this.lastCheckedRecord) {
this.toggleRecordShiftSelection(record);
} else {
record.toggleSelection();
}
this.lastCheckedRecord = record;
this.props.list.selectDomain(false);
}
toggleRecordShiftSelection(record) {
const { records } = this.props.list;
const recordIndex = records.indexOf(record);
const lastCheckedRecordIndex = records.indexOf(this.lastCheckedRecord);
const start = Math.min(recordIndex, lastCheckedRecordIndex);
const end = Math.max(recordIndex, lastCheckedRecordIndex);
const { selected } = record;
for (let i = start; i <= end; i++) {
records[i].toggleSelection(!selected);
}
}
async toggleOptionalField(fieldName) {
this.optionalActiveFields[fieldName] = !this.optionalActiveFields[fieldName];
this.state.columns = this.getActiveColumns(this.props.list);
@@ -217,7 +217,7 @@
t-on-touchend="() => this.onRowTouchEnd(record)"
t-on-touchmove="() => this.onRowTouchMove(record)"
>
<td t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev, group, record)" t-if="hasSelectors" class="o_list_record_selector" t-on-click.stop="() => this.toggleRecordSelection(record)" tabindex="-1">
<td t-on-keydown.synthetic="(ev) => this.onCellKeydown(ev, group, record)" t-if="hasSelectors" class="o_list_record_selector user-select-none" t-on-click.stop="() => this.toggleRecordSelection(record)" tabindex="-1">
<CheckBox disabled="!!props.list.editedRecord or props.list.model.useSampleModel" value="record.selected" onChange.bind="() => this.toggleRecordSelection(record)" />
</td>
<t t-foreach="getColumns(record)" t-as="column" t-key="column.id">
@@ -259,6 +259,158 @@ QUnit.module("Views", (hooks) => {
assert.isNotVisible(target.querySelector(".o_list_button_discard"));
});
QUnit.test("select record range with shift click", async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: '<tree><field name="foo"/><field name="int_field"/></tree>',
});
await click(target.querySelectorAll(".o_data_row .o_list_record_selector input")[0]);
assert.containsOnce(target.querySelector(".o_cp_buttons"), ".o_list_selection_box");
assert.containsNone(target.querySelector(".o_list_selection_box"), ".o_list_select_domain");
assert.strictEqual(
target.querySelector(".o_list_selection_box").textContent.trim(),
"1 selected"
);
assert.strictEqual(
document.querySelectorAll(".o_data_row .o_list_record_selector input:checked").length,
1
);
// shift click the 4th record to have 0-1-2-3 toggled
await triggerEvents(
target.querySelectorAll(".o_data_row .o_list_record_selector input")[3],
null,
[["keydown", { key: "Shift", shiftKey: true }], "click"]
);
assert.strictEqual(
target.querySelector(".o_list_selection_box").textContent.trim(),
"4 selected"
);
assert.strictEqual(
document.querySelectorAll(".o_data_row .o_list_record_selector input:checked").length,
4
);
// shift click the 3rd record to untoggle 2-3
await triggerEvents(
target.querySelectorAll(".o_data_row .o_list_record_selector input")[2],
null,
[["keydown", { key: "Shift", shiftKey: true }], "click"]
);
assert.strictEqual(
target.querySelector(".o_list_selection_box").textContent.trim(),
"2 selected"
);
assert.strictEqual(
document.querySelectorAll(".o_data_row .o_list_record_selector input:checked").length,
2
);
// shift click the 1st record to untoggle 0-1
await triggerEvents(
target.querySelectorAll(".o_data_row .o_list_record_selector input")[0],
null,
[["keydown", { key: "Shift", shiftKey: true }], "click"]
);
assert.strictEqual(
document.querySelectorAll(".o_data_row .o_list_record_selector input:checked").length,
0
);
});
QUnit.test("select record range with shift+space", async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: '<tree><field name="foo"/><field name="int_field"/></tree>',
});
// Go to the first checkbox and check it
triggerHotkey("ArrowDown");
triggerHotkey("ArrowDown");
await nextTick();
let checkbox = target.querySelector(
".o_data_row:nth-child(1) .o_list_record_selector input"
);
assert.strictEqual(document.activeElement, checkbox);
await click(checkbox);
assert.ok(checkbox.checked);
// Go to the fourth checkbox and shift+space
triggerHotkey("ArrowDown");
triggerHotkey("ArrowDown");
triggerHotkey("ArrowDown");
await nextTick();
checkbox = target.querySelector(".o_data_row:nth-child(4) .o_list_record_selector input");
assert.strictEqual(document.activeElement, checkbox);
assert.ok(!checkbox.checked);
await triggerEvents(document.activeElement, null, [
["keydown", { key: "Shift", shiftKey: true }],
["keydown", { key: " ", shiftKey: true }],
]);
// focus is on the input and not in the td cell
assert.strictEqual(document.activeElement.tagName, "INPUT");
// Check that all checkbox is checked
for (let i = 1; i < 5; i++) {
checkbox = target.querySelector(
`.o_data_row:nth-child(${i}) .o_list_record_selector input`
);
assert.ok(checkbox.checked);
}
});
QUnit.test("expand range of checkbox with shift+arrow", async function (assert) {
await makeView({
type: "list",
resModel: "foo",
serverData,
arch: '<tree><field name="foo"/><field name="int_field"/></tree>',
});
// Go to the first checkbox and check it
triggerHotkey("ArrowDown");
triggerHotkey("ArrowDown");
await nextTick();
let checkbox = target.querySelector(
".o_data_row:nth-child(1) .o_list_record_selector input"
);
assert.strictEqual(document.activeElement, checkbox);
await click(checkbox);
assert.ok(checkbox.checked);
// expand the checkbox with arrowdown
await triggerEvent(document.activeElement, null, "keydown", {
key: "Shift",
shiftKey: true,
});
triggerHotkey("shift+ArrowDown");
triggerHotkey("shift+ArrowDown");
triggerHotkey("shift+ArrowDown");
triggerHotkey("shift+ArrowUp");
await nextTick();
await triggerEvent(document.activeElement, null, "keyup", {
key: "Shift",
shiftKey: false,
});
checkbox = target.querySelector(".o_data_row:nth-child(3) .o_list_record_selector input");
assert.strictEqual(document.activeElement, checkbox);
assert.ok(checkbox.checked);
// Check that the three checkbox are checked
for (let i = 1; i < 4; i++) {
checkbox = target.querySelector(
`.o_data_row:nth-child(${i}) .o_list_record_selector input`
);
assert.ok(checkbox.checked);
}
});
QUnit.test("list with class", async function (assert) {
await makeView({
type: "list",
@@ -1993,8 +2145,7 @@ QUnit.module("Views", (hooks) => {
type: "list",
resModel: "foo",
serverData,
arch:
'<tree js_class="custom_list" editable="top"><field name="foo" required="1"/></tree>',
arch: '<tree js_class="custom_list" editable="top"><field name="foo" required="1"/></tree>',
mockRPC: async (route, args) => {
if (args.method === "write") {
assert.step(`write ${args.args[0]}`);
@@ -2035,8 +2186,7 @@ QUnit.module("Views", (hooks) => {
type: "list",
resModel: "foo",
serverData,
arch:
'<tree js_class="custom_list" editable="top" expand="1"><field name="foo" required="1"/></tree>',
arch: '<tree js_class="custom_list" editable="top" expand="1"><field name="foo" required="1"/></tree>',
groupBy: ["bar"],
mockRPC: async (route, args) => {
if (args.method === "write") {
@@ -3048,8 +3198,7 @@ QUnit.module("Views", (hooks) => {
resModel: "foo",
serverData,
groupBy: ["m2o"],
arch:
'<tree editable="bottom"><field name="foo" /><field name="int_field" sum="Sum"/></tree>',
arch: '<tree editable="bottom"><field name="foo" /><field name="int_field" sum="Sum"/></tree>',
});
const groupHeaders = target.querySelectorAll(".o_group_header");
assert.strictEqual(