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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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