first commit
This commit is contained in:
commit
1081cc2a36
509
ARCHITECTURE.md
Normal file
509
ARCHITECTURE.md
Normal 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
44
README.md
Normal 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 Odoo’s 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
1
__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
31
__manifest__.py
Normal file
31
__manifest__.py
Normal 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,
|
||||
}
|
||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
102
static/src/js/list_controller_group_select.js
Normal file
102
static/src/js/list_controller_group_select.js
Normal 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();
|
||||
},
|
||||
});
|
||||
181
static/src/js/list_renderer_group_select.js
Normal file
181
static/src/js/list_renderer_group_select.js
Normal 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);
|
||||
},
|
||||
});
|
||||
19
static/src/scss/list_view_group_select.scss
Normal file
19
static/src/scss/list_view_group_select.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
25
static/src/xml/list_renderer_templates.xml
Normal file
25
static/src/xml/list_renderer_templates.xml
Normal 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>
|
||||
1
views/webclient_templates.xml
Normal file
1
views/webclient_templates.xml
Normal file
@ -0,0 +1 @@
|
||||
<!-- Deprecated: assets are injected through manifest 'assets' section only. -->
|
||||
Loading…
Reference in New Issue
Block a user