feat: Implement group selection in list view by updating JavaScript logic, UI templates, styling, module manifest, and documentation.

This commit is contained in:
admin.suherdy 2025-12-06 19:12:42 +07:00
parent 1081cc2a36
commit 979337bbe7
8 changed files with 904 additions and 904 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,44 +1,44 @@
# List View Group Select
Enhances Odoo 17 list views by adding selection controls to grouped results.
Users can select all records in a group with a single click, including records in nested sub-groups.
## Features
- ✅ Checkbox injected into each group header for quick selection
- ✅ Recursive selection across nested sub-groups
- ✅ Partial-selection indicator when only some records are selected
- ✅ Action menu entry “Select All in Visible Groups”
- ✅ Compatible with bulk actions (Export, Archive/Unarchive, Delete, etc.)
## Installation
1. Clone or copy this module into your Odoo add-ons directory (`customaddons` or similar).
2. Restart the Odoo server.
3. Activate developer mode in Odoo.
4. Update the app list and install **List View Group Select**.
## Usage
1. Open any list view.
2. Apply at least one “Group By”.
3. Click the checkbox in the group header to toggle selection:
- **Unchecked** → no records selected
- **Indeterminate** → some records selected
- **Checked** → all records selected
4. Use the action menu “Select All in Visible Groups” to bulk-select every visible group.
## Configuration
No additional configuration is required.
The module automatically integrates with Odoos web assets.
## Compatibility
- Tested with Odoo 17.0 (community edition)
- Works for any model that supports grouped list views
- No Python models or database changes
## License
# List View Group Select
Enhances Odoo 17 list views by adding selection controls to grouped results.
Users can select all records in a group with a single click, including records in nested sub-groups.
## Features
- ✅ Checkbox injected into each group header for quick selection
- ✅ Recursive selection across nested sub-groups
- ✅ Partial-selection indicator when only some records are selected
- ✅ Action menu entry “Select All in Visible Groups”
- ✅ Compatible with bulk actions (Export, Archive/Unarchive, Delete, etc.)
## Installation
1. Clone or copy this module into your Odoo add-ons directory (`customaddons` or similar).
2. Restart the Odoo server.
3. Activate developer mode in Odoo.
4. Update the app list and install **List View Group Select**.
## Usage
1. Open any list view.
2. Apply at least one “Group By”.
3. Click the checkbox in the group header to toggle selection:
- **Unchecked** → no records selected
- **Indeterminate** → some records selected
- **Checked** → all records selected
4. Use the action menu “Select All in Visible Groups” to bulk-select every visible group.
## Configuration
No additional configuration is required.
The module automatically integrates with Odoos web assets.
## Compatibility
- Tested with Odoo 17.0 (community edition)
- Works for any model that supports grouped list views
- No Python models or database changes
## License
LGPL-3. See the LICENSE file supplied with Odoo for the framework license.

View File

@ -1,31 +1,31 @@
# -*- coding: utf-8 -*-
{
"name": "List View Group Select",
"summary": "Add recursive group selection controls to list views",
"description": """
List View Group Select
======================
Adds a checkbox to list view group headers allowing rapid selection or
deselection of all records within a group, including nested sub-groups.
Compatible with all standard bulk operations (Export, Archive, Delete, etc.).
""",
"version": "17.0.1.0.1",
"category": "Web",
"author": "Suherdy Yacob",
"website": "https://www.example.com",
"license": "LGPL-3",
"depends": ["web"],
"data": [],
"assets": {
"web.assets_backend": [
"list_view_group_select/static/src/js/list_renderer_group_select.js",
"list_view_group_select/static/src/js/list_controller_group_select.js",
"list_view_group_select/static/src/xml/list_renderer_templates.xml",
"list_view_group_select/static/src/scss/list_view_group_select.scss",
],
},
"installable": True,
"application": False,
"auto_install": False,
# -*- coding: utf-8 -*-
{
"name": "List View Group Select",
"summary": "Add recursive group selection controls to list views",
"description": """
List View Group Select
======================
Adds a checkbox to list view group headers allowing rapid selection or
deselection of all records within a group, including nested sub-groups.
Compatible with all standard bulk operations (Export, Archive, Delete, etc.).
""",
"version": "17.0.1.0.1",
"category": "Web",
"author": "Suherdy Yacob",
"website": "https://www.example.com",
"license": "LGPL-3",
"depends": ["web"],
"data": [],
"assets": {
"web.assets_backend": [
"list_view_group_select/static/src/js/list_renderer_group_select.js",
"list_view_group_select/static/src/js/list_controller_group_select.js",
"list_view_group_select/static/src/xml/list_renderer_templates.xml",
"list_view_group_select/static/src/scss/list_view_group_select.scss",
],
},
"installable": True,
"application": False,
"auto_install": False,
}

Binary file not shown.

View File

@ -1,102 +1,102 @@
/** @odoo-module **/
import { ListController } from "@web/views/list/list_controller";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
/**
* Ensure all records belonging to `group` (and its nested sub-groups) are loaded.
*
* @param {import("@web/model/relational_model/group").Group} group
*/
const ensureGroupFullyLoaded = async (group) => {
if (!group || !group.list) {
return;
}
const list = group.list;
if (list.isGrouped) {
for (const nested of list.groups || []) {
await ensureGroupFullyLoaded(nested);
}
return;
}
let targetCount = group.count ?? list.count ?? list.records.length;
if (list.hasLimitedCount) {
await list.fetchCount();
targetCount = Math.max(targetCount, list.count || 0);
}
targetCount = Math.max(targetCount, list.records.length);
if (targetCount > list.records.length) {
await list.load({ offset: 0, limit: targetCount });
}
};
/**
* Collect all records belonging to the provided group (including nested groups).
*
* @param {import("@web/model/relational_model/group").Group} group
* @param {ListController["model"]["root"]} rootList
* @returns {import("@web/model/relational_model/record").Record[]}
*/
const getGroupRecords = async (group, rootList) => {
if (!group) {
return [];
}
await ensureGroupFullyLoaded(group);
if (group.list.isGrouped) {
const records = [];
for (const nested of group.list.groups || []) {
records.push(...(await getGroupRecords(nested, rootList)));
}
return records;
}
return [...group.list.records];
};
/**
* Enumerate every top-level group visible on the controller.
*
* @param {ListController} controller
*/
const iterateVisibleGroups = (controller) => {
const mainList = controller.model.root;
const groups = [];
if (mainList.isGrouped) {
for (const group of mainList.groups) {
groups.push(group);
}
}
return groups;
};
patch(ListController.prototype, {
getStaticActionMenuItems() {
const items = super.getStaticActionMenuItems(...arguments);
items.selectAllGroups = {
isAvailable: () => this.model.root.isGrouped,
sequence: 5,
icon: "fa fa-check-square-o",
description: _t("Select All in Visible Groups"),
callback: () => this.onSelectAllGroups(),
};
return items;
},
async onSelectAllGroups() {
const list = this.model.root;
if (!list.isGrouped) {
return;
}
const groups = iterateVisibleGroups(this);
for (const group of groups) {
const records = await getGroupRecords(group, list);
for (const record of records) {
if (!record.selected) {
record.toggleSelection(true);
}
}
}
this.model.root.selectDomain(false);
this.model.root.notify();
},
/** @odoo-module **/
import { ListController } from "@web/views/list/list_controller";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
/**
* Ensure all records belonging to `group` (and its nested sub-groups) are loaded.
*
* @param {import("@web/model/relational_model/group").Group} group
*/
const ensureGroupFullyLoaded = async (group) => {
if (!group || !group.list) {
return;
}
const list = group.list;
if (list.isGrouped) {
for (const nested of list.groups || []) {
await ensureGroupFullyLoaded(nested);
}
return;
}
let targetCount = group.count ?? list.count ?? list.records.length;
if (list.hasLimitedCount) {
await list.fetchCount();
targetCount = Math.max(targetCount, list.count || 0);
}
targetCount = Math.max(targetCount, list.records.length);
if (targetCount > list.records.length) {
await list.load({ offset: 0, limit: targetCount });
}
};
/**
* Collect all records belonging to the provided group (including nested groups).
*
* @param {import("@web/model/relational_model/group").Group} group
* @param {ListController["model"]["root"]} rootList
* @returns {import("@web/model/relational_model/record").Record[]}
*/
const getGroupRecords = async (group, rootList) => {
if (!group) {
return [];
}
await ensureGroupFullyLoaded(group);
if (group.list.isGrouped) {
const records = [];
for (const nested of group.list.groups || []) {
records.push(...(await getGroupRecords(nested, rootList)));
}
return records;
}
return [...group.list.records];
};
/**
* Enumerate every top-level group visible on the controller.
*
* @param {ListController} controller
*/
const iterateVisibleGroups = (controller) => {
const mainList = controller.model.root;
const groups = [];
if (mainList.isGrouped) {
for (const group of mainList.groups) {
groups.push(group);
}
}
return groups;
};
patch(ListController.prototype, {
getStaticActionMenuItems() {
const items = super.getStaticActionMenuItems(...arguments);
items.selectAllGroups = {
isAvailable: () => this.model.root.isGrouped,
sequence: 5,
icon: "fa fa-check-square-o",
description: _t("Select All in Visible Groups"),
callback: () => this.onSelectAllGroups(),
};
return items;
},
async onSelectAllGroups() {
const list = this.model.root;
if (!list.isGrouped) {
return;
}
const groups = iterateVisibleGroups(this);
for (const group of groups) {
const records = await getGroupRecords(group, list);
for (const record of records) {
if (!record.selected) {
record.toggleSelection(true);
}
}
}
this.model.root.selectDomain(false);
this.model.root.notify();
},
});

View File

@ -1,181 +1,181 @@
/** @odoo-module **/
import { ListRenderer } from "@web/views/list/list_renderer";
import { patch } from "@web/core/utils/patch";
const collectGroupRecords = (rootGroup) => {
const records = [];
const seen = new Set();
const stack = rootGroup ? [rootGroup] : [];
while (stack.length) {
const group = stack.pop();
if (!group || !group.list) {
continue;
}
if (group.list.isGrouped) {
for (const nestedGroup of group.list.groups || []) {
stack.push(nestedGroup);
}
} else {
for (const record of group.list.records || []) {
if (!record || seen.has(record)) {
continue;
}
seen.add(record);
records.push(record);
}
}
}
return records;
};
const computeSelectionState = (records) => {
if (!records.length) {
return "none";
}
let selectedCount = 0;
for (const record of records) {
if (record.selected) {
selectedCount++;
}
}
if (selectedCount === 0) {
return "none";
}
if (selectedCount === records.length) {
return "full";
}
return "partial";
};
patch(ListRenderer.prototype, {
/**
* Return every record belonging to the provided group, traversing nested groups.
*
* @param {import("@web/model/relational_model/group").Group} group
* @returns {import("@web/model/relational_model/record").Record[]}
*/
getAllRecordsInGroup(group) {
return collectGroupRecords(group);
},
/**
* Compute the current selection state for the provided group.
*
* @param {import("@web/model/relational_model/group").Group} group
* @returns {"none"|"partial"|"full"}
*/
getGroupSelectionState(group) {
return computeSelectionState(this.getAllRecordsInGroup(group));
},
/**
* @param {import("@web/model/relational_model/group").Group} group
*/
isGroupSelectionFull(group) {
return this.getGroupSelectionState(group) === "full";
},
/**
* @param {import("@web/model/relational_model/group").Group} group
*/
isGroupSelectionPartial(group) {
return this.getGroupSelectionState(group) === "partial";
},
/**
* Handle click interaction on the custom group selector control.
*
* @param {MouseEvent} ev
* @param {import("@web/model/relational_model/group").Group} group
*/
onGroupCheckboxClick(ev, group) {
ev.stopPropagation();
ev.preventDefault();
this.toggleGroupSelection(group);
},
/**
* Handle keyboard interaction on the custom group selector control.
*
* @param {KeyboardEvent} ev
* @param {import("@web/model/relational_model/group").Group} group
*/
onGroupCheckboxKeydown(ev, group) {
const { key } = ev;
if (key === " " || key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
this.toggleGroupSelection(group);
}
},
/**
* Ensure all records (and nested sub-groups) for the provided group are loaded.
*
* @param {import("@web/model/relational_model/group").Group} group
*/
async ensureGroupFullyLoaded(group) {
if (!group || !group.list) {
return;
}
const list = group.list;
if (list.isGrouped) {
for (const nestedGroup of list.groups || []) {
await this.ensureGroupFullyLoaded(nestedGroup);
}
return;
}
let targetCount = group.count ?? list.count ?? list.records.length;
if (list.hasLimitedCount) {
await list.fetchCount();
targetCount = Math.max(targetCount, list.count || 0);
}
targetCount = Math.max(targetCount, list.records.length);
if (targetCount > list.records.length) {
await list.load({ offset: 0, limit: targetCount });
}
},
/**
* Apply a selection state to the provided group.
*
* @param {import("@web/model/relational_model/group").Group} group
* @param {boolean} [shouldSelect]
*/
async selectRecordsForGroup(group, shouldSelect) {
if (!this.canSelectRecord) {
return;
}
await this.ensureGroupFullyLoaded(group);
const records = this.getAllRecordsInGroup(group);
if (!records.length) {
return;
}
const selectionState = computeSelectionState(records);
const desiredSelection =
shouldSelect === undefined ? selectionState !== "full" : Boolean(shouldSelect);
for (const record of records) {
if (record.selected !== desiredSelection) {
record.toggleSelection(desiredSelection);
}
}
if (desiredSelection && records.length) {
this.lastCheckedRecord = records[records.length - 1];
}
this.props.list.selectDomain(false);
this.render(true);
},
/**
* Toggle selection for all records belonging to the provided group (including sub-groups).
*
* @param {import("@web/model/relational_model/group").Group} group
*/
async toggleGroupSelection(group) {
await this.selectRecordsForGroup(group);
},
/** @odoo-module **/
import { ListRenderer } from "@web/views/list/list_renderer";
import { patch } from "@web/core/utils/patch";
const collectGroupRecords = (rootGroup) => {
const records = [];
const seen = new Set();
const stack = rootGroup ? [rootGroup] : [];
while (stack.length) {
const group = stack.pop();
if (!group || !group.list) {
continue;
}
if (group.list.isGrouped) {
for (const nestedGroup of group.list.groups || []) {
stack.push(nestedGroup);
}
} else {
for (const record of group.list.records || []) {
if (!record || seen.has(record)) {
continue;
}
seen.add(record);
records.push(record);
}
}
}
return records;
};
const computeSelectionState = (records) => {
if (!records.length) {
return "none";
}
let selectedCount = 0;
for (const record of records) {
if (record.selected) {
selectedCount++;
}
}
if (selectedCount === 0) {
return "none";
}
if (selectedCount === records.length) {
return "full";
}
return "partial";
};
patch(ListRenderer.prototype, {
/**
* Return every record belonging to the provided group, traversing nested groups.
*
* @param {import("@web/model/relational_model/group").Group} group
* @returns {import("@web/model/relational_model/record").Record[]}
*/
getAllRecordsInGroup(group) {
return collectGroupRecords(group);
},
/**
* Compute the current selection state for the provided group.
*
* @param {import("@web/model/relational_model/group").Group} group
* @returns {"none"|"partial"|"full"}
*/
getGroupSelectionState(group) {
return computeSelectionState(this.getAllRecordsInGroup(group));
},
/**
* @param {import("@web/model/relational_model/group").Group} group
*/
isGroupSelectionFull(group) {
return this.getGroupSelectionState(group) === "full";
},
/**
* @param {import("@web/model/relational_model/group").Group} group
*/
isGroupSelectionPartial(group) {
return this.getGroupSelectionState(group) === "partial";
},
/**
* Handle click interaction on the custom group selector control.
*
* @param {MouseEvent} ev
* @param {import("@web/model/relational_model/group").Group} group
*/
onGroupCheckboxClick(ev, group) {
ev.stopPropagation();
ev.preventDefault();
this.toggleGroupSelection(group);
},
/**
* Handle keyboard interaction on the custom group selector control.
*
* @param {KeyboardEvent} ev
* @param {import("@web/model/relational_model/group").Group} group
*/
onGroupCheckboxKeydown(ev, group) {
const { key } = ev;
if (key === " " || key === "Enter") {
ev.preventDefault();
ev.stopPropagation();
this.toggleGroupSelection(group);
}
},
/**
* Ensure all records (and nested sub-groups) for the provided group are loaded.
*
* @param {import("@web/model/relational_model/group").Group} group
*/
async ensureGroupFullyLoaded(group) {
if (!group || !group.list) {
return;
}
const list = group.list;
if (list.isGrouped) {
for (const nestedGroup of list.groups || []) {
await this.ensureGroupFullyLoaded(nestedGroup);
}
return;
}
let targetCount = group.count ?? list.count ?? list.records.length;
if (list.hasLimitedCount) {
await list.fetchCount();
targetCount = Math.max(targetCount, list.count || 0);
}
targetCount = Math.max(targetCount, list.records.length);
if (targetCount > list.records.length) {
await list.load({ offset: 0, limit: targetCount });
}
},
/**
* Apply a selection state to the provided group.
*
* @param {import("@web/model/relational_model/group").Group} group
* @param {boolean} [shouldSelect]
*/
async selectRecordsForGroup(group, shouldSelect) {
if (!this.canSelectRecord) {
return;
}
await this.ensureGroupFullyLoaded(group);
const records = this.getAllRecordsInGroup(group);
if (!records.length) {
return;
}
const selectionState = computeSelectionState(records);
const desiredSelection =
shouldSelect === undefined ? selectionState !== "full" : Boolean(shouldSelect);
for (const record of records) {
if (record.selected !== desiredSelection) {
record.toggleSelection(desiredSelection);
}
}
if (desiredSelection && records.length) {
this.lastCheckedRecord = records[records.length - 1];
}
this.props.list.selectDomain(false);
this.render(true);
},
/**
* Toggle selection for all records belonging to the provided group (including sub-groups).
*
* @param {import("@web/model/relational_model/group").Group} group
*/
async toggleGroupSelection(group) {
await this.selectRecordsForGroup(group);
},
});

View File

@ -1,19 +1,19 @@
.o_list_group_selector {
display: inline-flex;
align-items: center;
.form-check-input {
cursor: pointer;
width: 1.1rem;
height: 1.1rem;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
& .form-check.indeterminate .form-check-input {
opacity: 0.7;
}
.o_list_group_selector {
display: inline-flex;
align-items: center;
.form-check-input {
cursor: pointer;
width: 1.1rem;
height: 1.1rem;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
& .form-check.indeterminate .form-check-input {
opacity: 0.7;
}
}

View File

@ -1,25 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="list_view_group_select.ListRendererGroupRow" t-inherit="web.ListRenderer.GroupRow" t-inherit-mode="extension">
<xpath expr="//th[@t-att-colspan='getGroupNameCellColSpan(group)']/div" position="inside">
<t t-if="hasSelectors">
<div class="o_list_group_selector me-2">
<div
class="form-check mb-0"
t-attf-class="{{ this.isGroupSelectionPartial(group) ? 'indeterminate' : '' }}"
>
<input
class="form-check-input"
type="checkbox"
tabindex="0"
t-att-checked="this.isGroupSelectionFull(group) ? 'checked' : undefined"
t-att-disabled="!this.canSelectRecord ? 'disabled' : undefined"
t-on-click.stop="(ev) => this.onGroupCheckboxClick(ev, group)"
t-on-keydown.stop="(ev) => this.onGroupCheckboxKeydown(ev, group)"
/>
</div>
</div>
</t>
</xpath>
</t>
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="list_view_group_select.ListRendererGroupRow" t-inherit="web.ListRenderer.GroupRow" t-inherit-mode="extension">
<xpath expr="//th[@t-att-colspan='getGroupNameCellColSpan(group)']/div" position="inside">
<t t-if="hasSelectors">
<div class="o_list_group_selector me-2">
<div
class="form-check mb-0"
t-attf-class="{{ this.isGroupSelectionPartial(group) ? 'indeterminate' : '' }}"
>
<input
class="form-check-input"
type="checkbox"
tabindex="0"
t-att-checked="this.isGroupSelectionFull(group) ? 'checked' : undefined"
t-att-disabled="!this.canSelectRecord ? 'disabled' : undefined"
t-on-click.stop="(ev) => this.onGroupCheckboxClick(ev, group)"
t-on-keydown.stop="(ev) => this.onGroupCheckboxKeydown(ev, group)"
/>
</div>
</div>
</t>
</xpath>
</t>
</templates>