first commit

This commit is contained in:
Suherdy Yacob 2025-10-29 08:52:25 +07:00
commit 1081cc2a36
10 changed files with 913 additions and 0 deletions

509
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,509 @@
# List View Group Select - Architecture & Implementation Plan
## Module Overview
**Name:** `list_view_group_select`
**Version:** 17.0.1.0.0
**Category:** Web
**Summary:** Adds group selection feature to Odoo 17 list views with recursive selection support
## Features
1. **Group Header Checkbox** - Add a checkbox in each group header to select all records in that group
2. **Recursive Selection** - Selecting a parent group selects all records in nested sub-groups
3. **Action Menu Integration** - Add "Select All Groups" option in the action menu
4. **Visual Feedback** - Show selection state (none/partial/full) for each group
5. **Compatible with Bulk Operations** - Works seamlessly with Export, Archive, Delete, etc.
## Technical Architecture
### Component Analysis (from Odoo Core)
#### 1. ListRenderer (`list_renderer.js`)
**Key Observations:**
- Line 674-682: `selectAll` getter checks if all records are selected
- Line 1867-1873: `toggleSelection()` handles selection of all records
- Line 1875-1887: `toggleRecordSelection()` handles individual record selection
- Line 1889-1900: `toggleRecordShiftSelection()` handles range selection
- Line 661-673: `nbRecordsInGroup()` recursively counts records in groups
- Line 1852-1857: `onGroupHeaderClicked()` handles group header clicks
#### 2. ListController (`list_controller.js`)
**Key Observations:**
- Line 317-362: `getStaticActionMenuItems()` defines action menu items
- Line 393-406: `onSelectDomain()` handles domain selection
- Line 408-413: `onUnselectAll()` clears all selections
#### 3. Group Structure
Groups have the following structure:
```javascript
group = {
list: {
records: [], // Direct records in this group
groups: [], // Sub-groups (for nested grouping)
isGrouped: bool // Whether this level has sub-groups
},
isFolded: bool,
aggregates: {},
displayName: string
}
```
### Module File Structure
```
customaddons/list_view_group_select/
├── __init__.py # Python package init (empty)
├── __manifest__.py # Module manifest
├── README.md # User documentation
├── ARCHITECTURE.md # This file
├── static/
│ ├── description/
│ │ └── icon.png # Module icon (optional)
│ └── src/
│ ├── js/
│ │ ├── list_renderer_group_select.js # Extended ListRenderer
│ │ └── list_controller_group_select.js # Extended ListController
│ ├── xml/
│ │ └── list_renderer_templates.xml # Template inheritance
│ └── scss/
│ └── list_view_group_select.scss # Styling
└── views/
└── webclient_templates.xml # Asset bundle definition
```
## Implementation Plan
### Phase 1: Module Structure Setup
#### File: `__manifest__.py`
```python
{
'name': 'List View Group Select',
'version': '17.0.1.0.0',
'category': 'Web',
'summary': 'Add group selection feature to list views',
'description': """
List View Group Select
======================
Extends Odoo list views with group selection capabilities:
* Add checkbox to group headers for selecting all records in a group
* Recursive selection for nested groups
* "Select All Groups" action in the action menu
* Compatible with all bulk operations (Export, Archive, Delete, etc.)
""",
'author': 'Your Company',
'website': 'https://www.example.com',
'license': 'LGPL-3',
'depends': ['web'],
'data': [
'views/webclient_templates.xml',
],
'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,
}
```
#### File: `__init__.py`
```python
# -*- coding: utf-8 -*-
# Empty file - this is a JavaScript-only module
```
### Phase 2: JavaScript Implementation
#### File: `static/src/js/list_renderer_group_select.js`
**Key Functions to Implement:**
1. **`getAllRecordsInGroup(group)`** - Recursive helper
- Traverses group hierarchy
- Collects all records including nested sub-groups
- Returns flat array of all records
2. **`toggleGroupSelection(group, ev)`** - Main selection logic
- Gets all records using `getAllRecordsInGroup()`
- Toggles selection state for all records
- Updates selection state tracking
3. **`getGroupSelectionState(group)`** - Visual feedback
- Returns: 'none', 'partial', 'full'
- Checks if 0, some, or all records are selected
- Used for checkbox visual state (unchecked/indeterminate/checked)
4. **`isGroupSelected(group)`** - Helper for templates
- Returns boolean for checkbox state
- Handles partially selected state
**Inheritance Pattern:**
```javascript
/** @odoo-module **/
import { ListRenderer } from "@web/views/list/list_renderer";
import { patch } from "@web/core/utils/patch";
patch(ListRenderer.prototype, {
// Override/extend methods here
getAllRecordsInGroup(group) {
// Recursive implementation
},
toggleGroupSelection(group, ev) {
// Main selection logic
},
getGroupSelectionState(group) {
// Return 'none', 'partial', or 'full'
},
// Other helper methods...
});
```
#### File: `static/src/js/list_controller_group_select.js`
**Key Functions to Implement:**
1. **Extend `getStaticActionMenuItems()`**
- Add new "Select All in Visible Groups" action
- Position it appropriately (sequence ~5, before Export)
- Only show when list is grouped
2. **`onSelectAllGroups()`** - New method
- Iterates through all visible groups
- Uses `getAllRecordsInGroup()` from renderer
- Selects all records in all groups
**Inheritance Pattern:**
```javascript
/** @odoo-module **/
import { ListController } from "@web/views/list/list_controller";
import { patch } from "@web/core/utils/patch";
import { _t } from "@web/core/l10n/translation";
patch(ListController.prototype, {
getStaticActionMenuItems() {
const items = super.getStaticActionMenuItems();
// Add group selection item
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;
},
onSelectAllGroups() {
// Implementation
},
});
```
### Phase 3: XML Template Extensions
#### File: `static/src/xml/list_renderer_templates.xml`
**Approach:**
- Inherit/extend the `web.ListRenderer.GroupRow` template
- Add checkbox element before the group name
- Bind to `toggleGroupSelection` method
- Apply appropriate CSS classes based on selection state
**Template Structure:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- Extend group header to add selection checkbox -->
<t t-name="web.ListRenderer.GroupRow" t-inherit="web.ListRenderer.GroupRow" t-inherit-mode="extension">
<!-- Add checkbox before group name -->
<xpath expr="//th[@t-att-colspan='getGroupNameCellColSpan(group)']" position="inside">
<t t-if="hasSelectors">
<div class="o_list_group_selector">
<input
type="checkbox"
class="o_list_record_selector"
t-att-checked="isGroupSelected(group) ? 'checked' : undefined"
t-att-indeterminate="getGroupSelectionState(group) === 'partial' ? 'indeterminate' : undefined"
t-on-click.stop="(ev) => this.toggleGroupSelection(group, ev)"
t-att-disabled="!canSelectRecord ? 'disabled' : undefined"
/>
</div>
</t>
</xpath>
</t>
</templates>
```
### Phase 4: Styling
#### File: `static/src/scss/list_view_group_select.scss`
```scss
.o_list_group_selector {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
input[type="checkbox"] {
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
// Indeterminate state styling
&:indeterminate {
opacity: 0.7;
}
}
}
// Ensure group header has proper alignment
.o_group_header th {
.o_list_group_selector {
+ * {
display: inline-block;
}
}
}
```
### Phase 5: Asset Bundle Registration
#### File: `views/webclient_templates.xml`
```xml
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/list_view_group_select/static/src/js/list_renderer_group_select.js"/>
<script type="text/javascript" src="/list_view_group_select/static/src/js/list_controller_group_select.js"/>
<link rel="stylesheet" type="text/scss" href="/list_view_group_select/static/src/scss/list_view_group_select.scss"/>
</xpath>
</template>
</odoo>
```
## Key Implementation Details
### Recursive Record Collection Algorithm
```javascript
getAllRecordsInGroup(group) {
const records = [];
if (group.list.isGrouped) {
// Has sub-groups - recurse
for (const subGroup of group.list.groups) {
records.push(...this.getAllRecordsInGroup(subGroup));
}
} else {
// Leaf group - collect direct records
records.push(...group.list.records);
}
return records;
}
```
### Selection State Calculation
```javascript
getGroupSelectionState(group) {
const allRecords = this.getAllRecordsInGroup(group);
if (allRecords.length === 0) {
return 'none';
}
const selectedCount = allRecords.filter(r => r.selected).length;
if (selectedCount === 0) {
return 'none';
} else if (selectedCount === allRecords.length) {
return 'full';
} else {
return 'partial';
}
}
```
### Group Selection Toggle
```javascript
async toggleGroupSelection(group, ev) {
if (!this.canSelectRecord) {
return;
}
ev.stopPropagation(); // Prevent group fold/unfold
const allRecords = this.getAllRecordsInGroup(group);
const state = this.getGroupSelectionState(group);
// If none or partial selected, select all; if all selected, deselect all
const shouldSelect = state !== 'full';
for (const record of allRecords) {
record.toggleSelection(shouldSelect);
}
// Ensure domain selection is turned off
this.props.list.selectDomain(false);
}
```
## Testing Strategy
### Test Cases
1. **Single-Level Grouping**
- Group by single field (e.g., Status)
- Select one group
- Verify all records in that group are selected
- Verify bulk actions work correctly
2. **Multi-Level Grouping**
- Group by Country, then City
- Select parent group (Country)
- Verify all records in all cities are selected
- Verify nested sub-groups are handled correctly
3. **Partial Selection Visual Feedback**
- Select some records in a group manually
- Verify group checkbox shows indeterminate state
- Click group checkbox
- Verify all records in group become selected
4. **Action Menu Integration**
- Select multiple groups using checkboxes
- Use "Select All in Visible Groups" action
- Verify all visible groups are selected
- Test with folded groups
5. **Bulk Operations**
- Select one or more groups
- Export selected records
- Archive selected records
- Delete selected records
- Verify correct record count in confirmations
6. **Edge Cases**
- Empty groups
- Mixed selection (some groups + individual records)
- Pagination within groups
- Group folding/unfolding with selection
## Integration Points
### With Odoo Core
- **ListRenderer.toggleSelection()** - Keep existing functionality
- **ListController actions** - Extend, don't replace
- **Selection state** - Use existing `record.selected` property
- **Bulk operations** - No changes needed, they use selected records
### Compatibility
- Works with all models that support list view
- Compatible with existing list view configurations
- No database changes required (JavaScript-only)
- Can be enabled/disabled per module install
## Performance Considerations
1. **Recursive Traversal**
- Cache group record collections if needed
- Limit recursion depth (Odoo typically has 2-3 group levels max)
2. **Selection Updates**
- Use batch updates where possible
- Avoid unnecessary re-renders
3. **Large Datasets**
- Respects existing pagination
- Only affects visible records
- Domain selection still available for very large datasets
## User Documentation (README.md)
### Installation
1. Copy module to `addons/` or `custom_addons/`
2. Update apps list
3. Install "List View Group Select"
### Usage
1. Open any list view and group by field(s)
2. Group headers will show selection checkboxes
3. Click checkbox to select all records in that group (including sub-groups)
4. Use action menu "Select All in Visible Groups" to select everything
5. Use standard bulk operations (Export, Archive, Delete) on selected records
### Features
- ✓ Single-level group selection
- ✓ Multi-level nested group selection
- ✓ Visual feedback (indeterminate state for partial selection)
- ✓ Action menu integration
- ✓ Works with all bulk operations
- ✓ Compatible with existing selection methods
## Future Enhancements (Optional)
1. **Configuration Options**
- Enable/disable per model
- Customize checkbox position
- Add keyboard shortcuts
2. **Advanced Features**
- "Select visible records" in folded groups
- Selection persistence across page loads
- Batch operations on specific groups
3. **UI Improvements**
- Group selection summary in action menu
- Quick selection dropdown
- Selection highlights
## Workflow Diagram
```mermaid
graph TD
A[User clicks group checkbox] --> B{Can select?}
B -->|No| C[Show notification]
B -->|Yes| D[Get all records in group]
D --> E{Is grouped?}
E -->|Yes| F[Recurse into sub-groups]
F --> D
E -->|No| G[Collect leaf records]
G --> H[Calculate current state]
H --> I{State?}
I -->|None/Partial| J[Select all records]
I -->|Full| K[Deselect all records]
J --> L[Update UI]
K --> L
L --> M[Enable bulk actions]
```
## Summary
This architecture provides a clean, maintainable solution that:
- Extends Odoo core functionality without modifying it
- Uses standard Odoo patterns (patch, inheritance)
- Maintains compatibility with existing features
- Provides intuitive user experience
- Handles edge cases properly
- Is performant and scalable
The implementation follows Odoo 17 best practices and OWL component patterns.

44
README.md Normal file
View File

@ -0,0 +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
LGPL-3. See the LICENSE file supplied with Odoo for the framework license.

1
__init__.py Normal file
View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

31
__manifest__.py Normal file
View File

@ -0,0 +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,
}

Binary file not shown.

View File

@ -0,0 +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();
},
});

View File

@ -0,0 +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);
},
});

View File

@ -0,0 +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;
}
}

View File

@ -0,0 +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>
</templates>

View File

@ -0,0 +1 @@
<!-- Deprecated: assets are injected through manifest 'assets' section only. -->