diff --git a/dependency_map_view/README.md b/dependency_map_view/README.md new file mode 100644 index 00000000000..4e569d7485c --- /dev/null +++ b/dependency_map_view/README.md @@ -0,0 +1,52 @@ +# Dependency & Impact Map View + +Interactive visual graph view for analyzing record dependencies and relationships in Odoo. + +## Features + +- **Visual Graph Representation**: Interactive network diagram showing record relationships +- **Multiple Relationship Types**: Support for Many2One, One2Many, and Many2Many fields +- **Color-Coded Relationships**: Different colors for different relationship types +- **Interactive Navigation**: Click on nodes to navigate to related records +- **Hierarchical Layout**: Automatic positioning with vis.js network library +- **Export Capabilities**: Export maps as PNG, JSON, or PDF +- **Real-time Analysis**: Dynamic relationship discovery and visualization +- **Auto-Integration**: Automatically available for all Odoo models + +## Installation + +1. Copy the module to your Odoo addons directory +2. Update the apps list in Odoo +3. Install the "Dependency & Impact Map View" module + +## Usage + +1. Go to any model's list view (e.g., Sales Orders, Customers, Products) +2. Click on the view switcher and select "Dependency Map" view +3. The system will automatically generate a visual map of record relationships +4. Click on nodes to navigate to related records +5. Use export options to save the map + +## Technical Details + +- **Framework**: Built with OWL (Odoo Web Library) +- **Visualization**: Uses vis.js network library +- **Compatibility**: Odoo 18.0+ +- **Auto-View Creation**: Automatically creates dependency_map views for any model +- **Performance**: Optimized for large datasets with progressive loading + +## Configuration + +No configuration required. The module automatically: +- Registers the new view type +- Creates default views for all models +- Integrates with existing Odoo interface + +## Author + +**Faizan Lodhi** +Website: https://axiomworld.net + +## License + +LGPL-3 \ No newline at end of file diff --git a/dependency_map_view/USAGE.md b/dependency_map_view/USAGE.md new file mode 100644 index 00000000000..2322a0c1f42 --- /dev/null +++ b/dependency_map_view/USAGE.md @@ -0,0 +1,167 @@ +# Dependency Map View - Usage Guide + +## Overview +The Dependency Map View provides a visual representation of record relationships in Odoo, helping users understand data connections and dependencies across different models. + +## Basic Usage + +### 1. Accessing the View +- Navigate to any model's list view (Sales Orders, Customers, Products, etc.) +- Click the view switcher button in the top-right corner +- Select "Dependency Map" from the dropdown + +### 2. Understanding the Map +- **Central Node**: The main record you're analyzing (purple) +- **Parent Nodes**: Records that reference this record (orange) +- **Child Nodes**: Records referenced by this record (blue/green) +- **Lines**: Show the relationship type and direction + +### 3. Color Coding +- **Purple**: Main/selected record +- **Orange**: Many2One relationships (parents) +- **Blue**: One2Many relationships (children) +- **Green**: Many2Many relationships (related) + +## Use Cases + +### 1. Customer Relationship Analysis +**Scenario**: Understanding a customer's complete business relationship + +**Steps**: +1. Go to Contacts → Customers +2. Switch to Dependency Map view +3. Select a customer record +4. View connected: Sales Orders, Invoices, Delivery Orders, Support Tickets + +**Benefits**: Complete customer 360° view for better service + +### 2. Product Impact Analysis +**Scenario**: Before discontinuing a product, check its usage + +**Steps**: +1. Go to Inventory → Products +2. Switch to Dependency Map view +3. Select the product +4. See connections: BOMs, Sales Orders, Purchase Orders, Stock Moves + +**Benefits**: Avoid disrupting active processes + +### 3. Sales Order Dependencies +**Scenario**: Understanding order fulfillment chain + +**Steps**: +1. Go to Sales → Orders +2. Switch to Dependency Map view +3. Select an order +4. View: Customer, Products, Invoices, Deliveries, Payments + +**Benefits**: Track order lifecycle and identify bottlenecks + +### 4. Project Management +**Scenario**: Analyzing project relationships + +**Steps**: +1. Go to Project → Projects +2. Switch to Dependency Map view +3. Select a project +4. See: Tasks, Team Members, Timesheets, Invoices, Contracts + +**Benefits**: Complete project oversight + +### 5. Financial Analysis +**Scenario**: Tracing invoice relationships + +**Steps**: +1. Go to Accounting → Invoices +2. Switch to Dependency Map view +3. Select an invoice +4. View: Customer, Sales Order, Payments, Journal Entries + +**Benefits**: Financial audit trail visualization + +## Advanced Features + +### Navigation +- **Click any node** to open that record in a new window +- **Hover over connections** to see relationship details +- **Zoom and pan** to explore large relationship networks + +### Export Options +- **PNG Export**: Save visual maps for presentations +- **JSON Export**: Export data for external analysis +- **PDF Export**: Generate reports with relationship diagrams + +### Performance Tips +- Maps are limited to 50 related records for performance +- System automatically excludes technical relationships (mail, ir models) +- Use filters to focus on specific relationship types + +## Business Benefits + +### 1. Data Quality Assurance +- Identify orphaned records +- Find missing relationships +- Validate data integrity + +### 2. Process Optimization +- Visualize workflow bottlenecks +- Understand process dependencies +- Optimize business flows + +### 3. Impact Analysis +- Assess change implications +- Plan system modifications +- Risk assessment for deletions + +### 4. Training & Documentation +- Visual process documentation +- New user training aid +- System understanding tool + +## Technical Notes + +### Supported Relationships +- **Many2One**: Customer → Sales Orders +- **One2Many**: Sales Order → Order Lines +- **Many2Many**: Products → Categories + +### Excluded Models +- Mail/messaging models (mail.*) +- System models (ir.*, base.*) +- Technical configuration models + +### Performance Considerations +- Automatic pagination for large datasets +- Lazy loading of relationship data +- Optimized queries for better performance + +## Troubleshooting + +### Common Issues + +**Q: Map shows "No relationships found"** +A: The record may not have any relational fields or all relationships are empty + +**Q: Some expected relationships are missing** +A: System excludes technical models and empty relationships for clarity + +**Q: Map loads slowly** +A: Large datasets may take time; consider using filters to reduce scope + +**Q: Export not working** +A: Ensure browser allows downloads and has sufficient permissions + +## Best Practices + +1. **Start Small**: Begin with simple records to understand the interface +2. **Use Filters**: Apply list filters before switching to map view +3. **Regular Analysis**: Use for periodic data quality checks +4. **Document Findings**: Export maps for process documentation +5. **Train Users**: Ensure team understands relationship meanings + +## Support + +For technical issues or feature requests: +- Check module documentation +- Contact system administrator +- Review Odoo community forums \ No newline at end of file diff --git a/dependency_map_view/__init__.py b/dependency_map_view/__init__.py new file mode 100644 index 00000000000..5d1de8724fc --- /dev/null +++ b/dependency_map_view/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import post_init_hook \ No newline at end of file diff --git a/dependency_map_view/__manifest__.py b/dependency_map_view/__manifest__.py new file mode 100644 index 00000000000..0a3a8750670 --- /dev/null +++ b/dependency_map_view/__manifest__.py @@ -0,0 +1,52 @@ +{ + 'name': 'Dependency & Impact Map View', + 'version': '18.0.1.0.0', + 'summary': ('Interactive visual graph view for analyzing record dependencies and ' + 'relationships'), + 'description': """ + Dependency & Impact Map View + ============================= + Features: + --------- + * Visual graph representation of record relationships + * Interactive network diagram with vis.js + * Support for Many2One, One2Many, and Many2Many relationships + * Color-coded relationship types + * Hierarchical layout with automatic positioning + * Click-to-navigate to related records + * Export capabilities (PNG, JSON, PDF) + * Real-time relationship analysis + * Automatic view integration for all models + + Technical: + ---------- + * Built with OWL framework + * Responsive design + * Optimized for large datasets + * Background PDF generation + * Progress tracking in systray + """, + 'category': 'Productivity/Tools', + 'author': 'Faizan Lodhi', + 'website': 'https://github.com/OCA/server-tools', + 'depends': ['base', 'web'], + 'data': [ + 'views/default_view.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'dependency_map_view/static/src/libs/vis-network.min.js', + 'dependency_map_view/static/src/components/map_view/map_view.js', + 'dependency_map_view/static/src/components/map_view/map_view.xml', + 'dependency_map_view/static/src/components/map_view/map_view.scss', + ], + }, + 'images': ['static/description/icon.png'], + 'post_init_hook': 'post_init_hook', + 'installable': True, + 'application': False, + 'auto_install': False, + 'license': 'LGPL-3', + 'price': 0.00, + 'currency': 'USD', +} diff --git a/dependency_map_view/hooks.py b/dependency_map_view/hooks.py new file mode 100644 index 00000000000..77dd4588dbf --- /dev/null +++ b/dependency_map_view/hooks.py @@ -0,0 +1,6 @@ +def post_init_hook(env): + actions = env['ir.actions.act_window'].search([('view_mode', '!=', False)]) + + for action in actions: + if 'dependency_map' not in action.view_mode: + action.view_mode = action.view_mode + ',dependency_map' \ No newline at end of file diff --git a/dependency_map_view/img.png b/dependency_map_view/img.png new file mode 100644 index 00000000000..847d5268c09 Binary files /dev/null and b/dependency_map_view/img.png differ diff --git a/dependency_map_view/models/__init__.py b/dependency_map_view/models/__init__.py new file mode 100644 index 00000000000..26d6e91f9c7 --- /dev/null +++ b/dependency_map_view/models/__init__.py @@ -0,0 +1 @@ +from . import ir_ui_view \ No newline at end of file diff --git a/dependency_map_view/models/ir_actions.py b/dependency_map_view/models/ir_actions.py new file mode 100644 index 00000000000..e5ce60944e3 --- /dev/null +++ b/dependency_map_view/models/ir_actions.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" +Window Action Extension for Dependency Map View + +This module extends the ir.actions.act_window model to automatically add +the dependency_map view mode to newly created actions when the target model +contains relational fields (Many2One, One2Many, or Many2Many). + +The extension intelligently detects whether a model would benefit from the +dependency map view by checking for the presence of relational fields, and +excludes system models (ir.* and mail.*) that typically don't need this view. + +""" + +from odoo import models, api + + +class IrActionsActWindow(models.Model): + """ + Extension of ir.actions.act_window to auto-add dependency map view. + + This class extends the standard Odoo window action model to automatically + include the 'dependency_map' view mode for models that have relational + fields, making the dependency visualization available without manual + configuration. + + Attributes: + _inherit (str): Inherits from 'ir.actions.act_window' model + """ + + _inherit = 'ir.actions.act_window' + + @api.model_create_multi + def create(self, vals_list): + """ + Override create method to auto-add dependency_map view mode. + + This method intercepts the creation of new window actions and + automatically appends 'dependency_map' to the view_mode if: + 1. The action has a view_mode defined + 2. The dependency_map is not already present + 3. The target model is not a system model (ir.* or mail.*) + 4. The target model has at least one relational field + + Args: + vals_list (list): List of dictionaries containing values for + creating new action records. Each dictionary represents + one action to be created. + + Returns: + recordset: The newly created ir.actions.act_window records + + Example: + When creating an action for 'res.partner' model: + Input: {'name': 'Partners', 'res_model': 'res.partner', + 'view_mode': 'tree,form'} + Result: view_mode becomes 'tree,form,dependency_map' + + Note: + - System models (ir.*, mail.*) are excluded to avoid cluttering + technical views + - Models without relational fields are skipped as they wouldn't + benefit from dependency visualization + - Errors during field checking are silently caught to prevent + action creation failures + """ + # Call parent create method to create the actions + actions = super().create(vals_list) + + # Process each newly created action + for action in actions: + # Check if action has view_mode and dependency_map is not already present + if action.view_mode and 'dependency_map' not in action.view_mode: + # Verify the action has a target model and it's not a system model + if action.res_model and not action.res_model.startswith(('ir.', 'mail.')): + # Check if model has relational fields that would benefit from dependency map + try: + # Get the model instance + model = self.env[action.res_model] + + # Retrieve all field definitions for the model + fields = model.fields_get() + + # Check if any field is a relational field type + has_relations = any( + f.get('type') in ['many2one', 'one2many', 'many2many'] + for f in fields.values() + ) + + # If model has relational fields, add dependency_map view + if has_relations: + action.view_mode = action.view_mode + ',dependency_map' + except Exception: + # Silently pass if there's any error checking the model + # This prevents breaking action creation due to model access issues + pass + + return actions diff --git a/dependency_map_view/models/ir_ui_view.py b/dependency_map_view/models/ir_ui_view.py new file mode 100644 index 00000000000..fedf974923d --- /dev/null +++ b/dependency_map_view/models/ir_ui_view.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +""" +UI View Extension for Dependency Map View Type + +This module extends the ir.ui.view model to add 'dependency_map' as a new +view type in Odoo. It enables the system to recognize and handle dependency +map views, and automatically creates default views when needed. + +The extension allows any model to have a dependency map view without requiring +manual view definition in XML. If a dependency_map view is requested but doesn't +exist, it will be automatically generated. + + +""" + +from odoo import fields, models, api + + +class View(models.Model): + """ + Extension of ir.ui.view to support dependency_map view type. + + This class extends Odoo's view model to register 'dependency_map' as a + valid view type alongside standard views (tree, form, kanban, etc.). + It also provides automatic view creation functionality when a dependency + map view is requested but doesn't exist. + + Attributes: + _inherit (str): Inherits from 'ir.ui.view' model + type (fields.Selection): Extended selection field to include dependency_map + """ + + _inherit = 'ir.ui.view' + + # Extend the type selection field to include 'dependency_map' as a valid view type + type = fields.Selection( + selection_add=[('dependency_map', 'Dependency Map')], + ondelete={'dependency_map': 'cascade'} # Delete views when view type is removed + ) + + @api.model + def default_view(self, model, view_type): + """ + Auto-create dependency_map view if it doesn't exist for a model. + + This method overrides the default_view method to provide automatic + view creation for dependency_map views. When a dependency_map view + is requested for a model but doesn't exist, this method creates a + minimal default view automatically. + + This eliminates the need to manually define dependency_map views in + XML for every model, as the view will be generated on-demand with + a standard structure. + + Args: + model (str): The technical name of the model (e.g., 'res.partner') + view_type (str): The type of view being requested (e.g., 'form', + 'tree', 'dependency_map') + + Returns: + int: The database ID of the view record (either existing or newly created) + + Example: + When requesting dependency_map view for 'sale.order': + - If view exists: Returns existing view ID + - If view doesn't exist: Creates new view with minimal arch and returns its ID + + Note: + - Only handles 'dependency_map' view type; other types are passed to parent + - Created views have a standardized naming convention: {model}.dependency.map.auto + - The arch is minimal: '' as the frontend handles rendering + - Views are created with 'primary' mode to be used as default + """ + # Check if the requested view type is dependency_map + if view_type == 'dependency_map': + # Search for existing dependency_map view for this model + view = self.search([ + ('model', '=', model), + ('type', '=', 'dependency_map') + ], limit=1) + + # If no view exists, create a default one automatically + if not view: + view = self.create({ + 'name': f'{model}.dependency.map.auto', # Auto-generated name + 'model': model, # Target model + 'type': 'dependency_map', # View type + 'arch': '', # Minimal XML architecture + 'mode': 'primary', # Set as primary view for this type + }) + + # Return the view ID (either found or created) + return view.id + + # For all other view types, use the standard parent method + return super().default_view(model, view_type) + + def _get_view_info(self): + # Get the original dictionary + view_info = super()._get_view_info() + + view_info['dependency_map'] = { + 'icon': 'fa fa-code-fork', + 'multi_record': True, + } + + return view_info diff --git a/dependency_map_view/pyproject.toml b/dependency_map_view/pyproject.toml new file mode 100644 index 00000000000..2cc68ce25e8 --- /dev/null +++ b/dependency_map_view/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" + +[tool.whool] +addons-dirs = ["."] +depends = ["odoo-addons-oca-web"] \ No newline at end of file diff --git a/dependency_map_view/static/description/icon.png b/dependency_map_view/static/description/icon.png new file mode 100644 index 00000000000..7108c3aa8a5 Binary files /dev/null and b/dependency_map_view/static/description/icon.png differ diff --git a/dependency_map_view/static/src/components/map_view/map_view.js b/dependency_map_view/static/src/components/map_view/map_view.js new file mode 100644 index 00000000000..3b43cf54de4 --- /dev/null +++ b/dependency_map_view/static/src/components/map_view/map_view.js @@ -0,0 +1,304 @@ +/* global vis */ // Added to fix 'vis is not defined' error, assuming vis-network.min.js is loaded via assets + +import { registry } from "@web/core/registry"; +import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { Layout } from "@web/search/layout"; + +export class DependencyMapRenderer extends Component { + static template = "dependency_map_view.MapView"; + static props = ["*"]; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.containerRef = useRef("mapContainer"); + this.state = useState({ + loading: false, + records: [], + selectedRecord: null, + }); + this.network = null; + + onMounted(() => this.loadRecords()); + onWillUnmount(() => { + if (this.network) this.network.destroy(); + }); + } + + async loadRecords() { + this.state.loading = true; + const model = this.props.resModel || 'res.partner'; + const records = await this.orm.searchRead(model, [], ['id', 'display_name'], { limit: 50 }); + this.state.records = records; + this.state.loading = false; + } + + async selectRecord(record) { + this.state.selectedRecord = record; + await this.renderMap(record); + } + + /** + * Refactored to reduce complexity and block depth by using helper methods. + */ + async renderMap(record) { + if (!record) return; + + this.state.loading = true; + const model = this.props.resModel || 'res.partner'; + + const nodes = []; + const edges = []; + + // Add the central node + nodes.push({ + id: record.id, + label: record.display_name, + shape: 'box', + color: { background: '#9b59b6', border: '#8e44ad' }, + font: { size: 14, color: '#ffffff', bold: true }, + margin: 10, + level: 0, + }); + + const fields = await this.orm.call(model, 'fields_get', [], { + attributes: ['type', 'relation', 'string'] + }); + + for (const [fieldName, fieldInfo] of Object.entries(fields)) { + // Skip non-relational and specific Odoo internal models + if (!['many2one', 'one2many', 'many2many'].includes(fieldInfo.type)) continue; + if (!fieldInfo.relation) continue; + if (fieldInfo.relation.includes('mail.') || fieldInfo.relation.includes('ir.')) continue; + + const fullRecord = await this.orm.searchRead(model, [['id', '=', record.id]], [fieldName]); + + if (fullRecord.length > 0 && fullRecord[0][fieldName]) { + const relValue = fullRecord[0][fieldName]; + const isManyToOne = fieldInfo.type === 'many2one'; + + if (isManyToOne) { + await this._handleManyToOne(record, fieldName, fieldInfo, relValue, nodes, edges); + } + else if (Array.isArray(relValue) && relValue.length > 0) { + await this._handleToMany(record, fieldName, fieldInfo, relValue, nodes, edges); + } + } + } + + this.drawNetwork(nodes, edges); + this.state.loading = false; + } + + /** + * Helper to handle many2one fields. Fixes: init-declarations (relId, relName) + */ + async _handleManyToOne(record, fieldName, fieldInfo, relValue, nodes, edges) { + let relId = null; // Initialized + let relName = ''; // Initialized + + // Case 1: [id, name] tuple format + if (Array.isArray(relValue) && relValue.length === 2 && typeof relValue[0] === 'number') { + relId = relValue[0]; + relName = relValue[1]; + } + // Case 2: Just ID (number) + else if (typeof relValue === 'number') { + relId = relValue; + try { + const relRecords = await this.orm.searchRead(fieldInfo.relation, [['id', '=', relId]], ['display_name']); + relName = relRecords[0]?.display_name || `ID: ${relId}`; + } catch (e) { + // Fixed no-unused-vars by logging 'e' + console.error('Error fetching many2one relation:', fieldName, relId, e); + relName = `ID: ${relId}`; + } + } + + if (relId) { + const relNodeId = `${fieldInfo.relation}_${relId}_${fieldName}`; + if (!nodes.find(n => n.id === relNodeId)) { + nodes.push({ + id: relNodeId, + label: relName, + shape: 'box', + color: { background: '#f39c12', border: '#e67e22' }, + font: { size: 12, color: '#ffffff' }, + margin: 8, + level: -1, + }); + } + edges.push({ + from: relNodeId, + to: record.id, + arrows: 'to', + label: fieldInfo.string, + color: { color: '#e67e22' }, + width: 2, + }); + } + } + + /** + * Helper to handle one2many and many2many fields. Fixes: no-unused-vars (edgeStyle) + */ + async _handleToMany(record, fieldName, fieldInfo, relValue, nodes, edges) { + const isManyToMany = fieldInfo.type === 'many2many'; + const nodeColor = isManyToMany + ? { background: '#27ae60', border: '#229954' } + : { background: '#3498db', border: '#2980b9' }; + // Removed unused variable 'edgeStyle' + + const validIds = relValue.filter(id => typeof id === 'number'); + for (const relId of validIds) { + try { + const relRecords = await this.orm.searchRead(fieldInfo.relation, [['id', '=', relId]], ['id', 'display_name']); + if (relRecords.length > 0) { + const relNodeId = `${fieldInfo.relation}_${relId}`; + if (!nodes.find(n => n.id === relNodeId)) { + nodes.push({ + id: relNodeId, + label: relRecords[0].display_name || `ID: ${relId}`, + shape: 'box', + color: nodeColor, + font: { size: 12, color: '#ffffff' }, + margin: 8, + level: 1, + }); + } + const edgeColor = isManyToMany ? '#229954' : '#2980b9'; + edges.push({ + from: record.id, + to: relNodeId, + arrows: 'to', + label: fieldInfo.string, + color: { color: edgeColor }, + width: 2, + }); + } + } catch (e) { + // Fixed no-undef (console) and no-unused-vars + console.error('Error fetching relation:', fieldName, relId, e); + } + } + } + + drawNetwork(nodes, edges) { + if (this.network) this.network.destroy(); + + const container = this.containerRef.el; + const data = { nodes, edges }; + const options = { + layout: { + hierarchical: { + enabled: true, + direction: 'UD', + sortMethod: 'directed', + levelSeparation: 150, + nodeSpacing: 200, + treeSpacing: 250, + blockShifting: true, + edgeMinimization: true, + parentCentralization: true, + } + }, + physics: { enabled: false }, + nodes: { + shape: 'box', + font: { + size: 13, + face: 'Arial', + color: '#ffffff' + }, + borderWidth: 2, + borderWidthSelected: 3, + shadow: { + enabled: true, + color: 'rgba(0,0,0,0.2)', + size: 10, + x: 3, + y: 3 + }, + margin: 10, + widthConstraint: { + minimum: 120, + maximum: 250 + } + }, + edges: { + width: 2, + font: { + size: 11, + color: '#666666', + background: '#ffffff', + strokeWidth: 0 + }, + smooth: { + enabled: true, + type: 'cubicBezier', + roundness: 0.5 + }, + arrows: { + to: { + enabled: true, + scaleFactor: 0.8 + } + } + }, + interaction: { + hover: true, + zoomView: true, + dragView: true, + selectConnectedEdges: false + } + }; + + this.network = new vis.Network(container, data, options); + + this.network.on('click', (params) => { + if (params.nodes.length > 0) { + const nodeId = params.nodes[0]; + let model = this.props.resModel; // Initialized + let id = null; // Initialized + + if (typeof nodeId === 'number') { + model = this.props.resModel; + id = nodeId; + } else { + // Fixes no-undef (model, id) and radix warning + const parts = nodeId.split('_'); + model = parts[0]; + // The ID is always the second part if the format is consistent + id = parseInt(parts[1], 10); // Added radix 10 + } + + if (id && model) { + this.action.doAction({ + type: 'ir.actions.act_window', + res_model: model, + res_id: id, + views: [[false, 'form']], + target: 'new', + }); + } + } + }); + } +} + +export class DependencyMapController extends Component { + static template = "dependency_map_view.MapController"; + static components = { Layout, DependencyMapRenderer }; + static props = ["*"]; +} + +export const dependencyMapView = { + type: "dependency_map", + display_name: "Dependency Map", + icon: "fa fa-code-fork", + multiRecord: true, + Controller: DependencyMapController, +}; + +registry.category("views").add("dependency_map", dependencyMapView); diff --git a/dependency_map_view/static/src/components/map_view/map_view.scss b/dependency_map_view/static/src/components/map_view/map_view.scss new file mode 100644 index 00000000000..06349272a35 --- /dev/null +++ b/dependency_map_view/static/src/components/map_view/map_view.scss @@ -0,0 +1,178 @@ +.o_dependency_map_view { + height: 100%; + display: flex; + flex-direction: column; +} + +.o_dependency_map_container { + flex: 1; + display: flex; + background: #ecf0f1; + overflow: hidden; +} + +.o_map_sidebar { + width: 280px; + background: white; + border-right: 2px solid #bdc3c7; + display: flex; + flex-direction: column; + + .sidebar_header { + padding: 15px; + border-bottom: 2px solid #ecf0f1; + background: #f8f9fa; + + h5 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #2c3e50; + } + } + + .sidebar_content { + flex: 1; + overflow-y: auto; + + .record_item { + padding: 10px 15px; + cursor: pointer; + border-bottom: 1px solid #ecf0f1; + transition: all 0.2s; + display: flex; + align-items: center; + + &:hover { + background: #f8f9fa; + } + + &.active { + background: #3498db; + color: white; + font-weight: 500; + + i { + color: white; + } + } + + i { + color: #95a5a6; + font-size: 8px; + } + + span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } +} + +.o_map_content { + flex: 1; + position: relative; + background: #f5f7fa; + background-image: + linear-gradient(rgba(200, 200, 200, 0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(200, 200, 200, 0.1) 1px, transparent 1px); + background-size: 20px 20px; +} + +.o_map_toolbar { + position: absolute; + top: 15px; + left: 15px; + z-index: 10; + display: flex; + gap: 10px; + + .btn { + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + + &.active { + background: #3498db; + color: white; + border-color: #2980b9; + } + } +} + +.o_map_legend { + position: absolute; + top: 15px; + right: 15px; + background: white; + padding: 15px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + z-index: 10; + min-width: 200px; + + h6 { + margin: 0 0 10px 0; + font-size: 13px; + font-weight: 600; + color: #2c3e50; + } + + .legend_item { + display: flex; + align-items: center; + margin: 8px 0; + font-size: 12px; + + .legend_dot { + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 8px; + border: 2px solid rgba(0,0,0,0.2); + } + } + + hr { + margin: 10px 0; + } + + small { + display: block; + line-height: 1.6; + } +} + +.o_map_canvas { + width: 100%; + height: 100%; + + // Override vis.js default styles for cleaner look + canvas { + outline: none !important; + } +} + +.o_map_empty { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: #95a5a6; + + i { + color: #bdc3c7; + } + + h4 { + color: #7f8c8d; + margin-bottom: 10px; + } + + p { + color: #95a5a6; + font-size: 14px; + } +} diff --git a/dependency_map_view/static/src/components/map_view/map_view.xml b/dependency_map_view/static/src/components/map_view/map_view.xml new file mode 100644 index 00000000000..f1f5d9092df --- /dev/null +++ b/dependency_map_view/static/src/components/map_view/map_view.xml @@ -0,0 +1,74 @@ + + + + + + + + +
+
+ + +
+ +
+ +
+ +

Select a Record

+

Choose a record from the sidebar

+
+
+ + +
+
Relationship Types
+
+ + Selected Record +
+
+ + Many2One (Parent) +
+
+ + One2Many (Children) +
+
+ + Many2Many (Tags) +
+
+ + Lines:
+ Solid = One2Many
+ Dashed = Many2One/Many2Many +
+
+ +
+ +
+
+ + diff --git a/dependency_map_view/static/src/libs/vis-network.min.js b/dependency_map_view/static/src/libs/vis-network.min.js new file mode 100644 index 00000000000..bff4916d2f0 --- /dev/null +++ b/dependency_map_view/static/src/libs/vis-network.min.js @@ -0,0 +1,27 @@ +/** + * vis-network + * https://visjs.github.io/vis-network/ + * + * A dynamic, browser-based visualization library. + * + * @version 9.1.2 + * @date 2022-03-28T20:17:35.342Z + * + * @copyright (c) 2011-2017 Almende B.V, http://almende.com + * @copyright (c) 2017-2019 visjs contributors, https://github.com/visjs + * + * @license + * vis.js is dual licensed under both + * + * 1. The Apache 2.0 License + * http://www.apache.org/licenses/LICENSE-2.0 + * + * and + * + * 2. The MIT License + * http://opensource.org/licenses/MIT + * + * vis.js may be distributed under either license. + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).vis=t.vis||{})}(this,(function(t){"use strict";var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{},i=function(t){return t&&t.Math==Math&&t},n=i("object"==typeof globalThis&&globalThis)||i("object"==typeof window&&window)||i("object"==typeof self&&self)||i("object"==typeof e&&e)||function(){return this}()||Function("return this")(),o=function(t){try{return!!t()}catch(t){return!0}},r=!o((function(){var t=function(){}.bind();return"function"!=typeof t||t.hasOwnProperty("prototype")})),s=r,a=Function.prototype,h=a.apply,l=a.call,d="object"==typeof Reflect&&Reflect.apply||(s?l.bind(h):function(){return l.apply(h,arguments)}),c=r,u=Function.prototype,f=u.bind,p=u.call,v=c&&f.bind(p,p),g=c?function(t){return t&&v(t)}:function(t){return t&&function(){return p.apply(t,arguments)}},y=function(t){return"function"==typeof t},m={},b=!o((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]})),w=r,k=Function.prototype.call,_=w?k.bind(k):function(){return k.apply(k,arguments)},x={},E={}.propertyIsEnumerable,O=Object.getOwnPropertyDescriptor,C=O&&!E.call({1:2},1);x.f=C?function(t){var e=O(this,t);return!!e&&e.enumerable}:E;var S,T,M=function(t,e){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:e}},P=g,D=P({}.toString),I=P("".slice),B=function(t){return I(D(t),8,-1)},z=g,N=o,F=B,A=n.Object,j=z("".split),R=N((function(){return!A("z").propertyIsEnumerable(0)}))?function(t){return"String"==F(t)?j(t,""):A(t)}:A,L=n.TypeError,H=function(t){if(null==t)throw L("Can't call method on "+t);return t},W=R,q=H,V=function(t){return W(q(t))},U=y,Y=function(t){return"object"==typeof t?null!==t:U(t)},X={},G=X,K=n,$=y,Z=function(t){return $(t)?t:void 0},Q=function(t,e){return arguments.length<2?Z(G[t])||Z(K[t]):G[t]&&G[t][e]||K[t]&&K[t][e]},J=g({}.isPrototypeOf),tt=Q("navigator","userAgent")||"",et=n,it=tt,nt=et.process,ot=et.Deno,rt=nt&&nt.versions||ot&&ot.version,st=rt&&rt.v8;st&&(T=(S=st.split("."))[0]>0&&S[0]<4?1:+(S[0]+S[1])),!T&&it&&(!(S=it.match(/Edge\/(\d+)/))||S[1]>=74)&&(S=it.match(/Chrome\/(\d+)/))&&(T=+S[1]);var at=T,ht=at,lt=o,dt=!!Object.getOwnPropertySymbols&&!lt((function(){var t=Symbol();return!String(t)||!(Object(t)instanceof Symbol)||!Symbol.sham&&ht&&ht<41})),ct=dt&&!Symbol.sham&&"symbol"==typeof Symbol.iterator,ut=Q,ft=y,pt=J,vt=ct,gt=n.Object,yt=vt?function(t){return"symbol"==typeof t}:function(t){var e=ut("Symbol");return ft(e)&&pt(e.prototype,gt(t))},mt=n.String,bt=function(t){try{return mt(t)}catch(t){return"Object"}},wt=y,kt=bt,_t=n.TypeError,xt=function(t){if(wt(t))return t;throw _t(kt(t)+" is not a function")},Et=xt,Ot=function(t,e){var i=t[e];return null==i?void 0:Et(i)},Ct=_,St=y,Tt=Y,Mt=n.TypeError,Pt={exports:{}},Dt=n,It=Object.defineProperty,Bt=function(t,e){try{It(Dt,t,{value:e,configurable:!0,writable:!0})}catch(i){Dt[t]=e}return e},zt="__core-js_shared__",Nt=n[zt]||Bt(zt,{}),Ft=Nt;(Pt.exports=function(t,e){return Ft[t]||(Ft[t]=void 0!==e?e:{})})("versions",[]).push({version:"3.21.1",mode:"pure",copyright:"© 2014-2022 Denis Pushkarev (zloirock.ru)",license:"https://github.com/zloirock/core-js/blob/v3.21.1/LICENSE",source:"https://github.com/zloirock/core-js"});var At=H,jt=n.Object,Rt=function(t){return jt(At(t))},Lt=Rt,Ht=g({}.hasOwnProperty),Wt=Object.hasOwn||function(t,e){return Ht(Lt(t),e)},qt=g,Vt=0,Ut=Math.random(),Yt=qt(1..toString),Xt=function(t){return"Symbol("+(void 0===t?"":t)+")_"+Yt(++Vt+Ut,36)},Gt=n,Kt=Pt.exports,$t=Wt,Zt=Xt,Qt=dt,Jt=ct,te=Kt("wks"),ee=Gt.Symbol,ie=ee&&ee.for,ne=Jt?ee:ee&&ee.withoutSetter||Zt,oe=function(t){if(!$t(te,t)||!Qt&&"string"!=typeof te[t]){var e="Symbol."+t;Qt&&$t(ee,t)?te[t]=ee[t]:te[t]=Jt&&ie?ie(e):ne(e)}return te[t]},re=_,se=Y,ae=yt,he=Ot,le=function(t,e){var i,n;if("string"===e&&St(i=t.toString)&&!Tt(n=Ct(i,t)))return n;if(St(i=t.valueOf)&&!Tt(n=Ct(i,t)))return n;if("string"!==e&&St(i=t.toString)&&!Tt(n=Ct(i,t)))return n;throw Mt("Can't convert object to primitive value")},de=oe,ce=n.TypeError,ue=de("toPrimitive"),fe=function(t,e){if(!se(t)||ae(t))return t;var i,n=he(t,ue);if(n){if(void 0===e&&(e="default"),i=re(n,t,e),!se(i)||ae(i))return i;throw ce("Can't convert object to primitive value")}return void 0===e&&(e="number"),le(t,e)},pe=yt,ve=function(t){var e=fe(t,"string");return pe(e)?e:e+""},ge=Y,ye=n.document,me=ge(ye)&&ge(ye.createElement),be=function(t){return me?ye.createElement(t):{}},we=be,ke=!b&&!o((function(){return 7!=Object.defineProperty(we("div"),"a",{get:function(){return 7}}).a})),_e=b,xe=_,Ee=x,Oe=M,Ce=V,Se=ve,Te=Wt,Me=ke,Pe=Object.getOwnPropertyDescriptor;m.f=_e?Pe:function(t,e){if(t=Ce(t),e=Se(e),Me)try{return Pe(t,e)}catch(t){}if(Te(t,e))return Oe(!xe(Ee.f,t,e),t[e])};var De=o,Ie=y,Be=/#|\.prototype\./,ze=function(t,e){var i=Fe[Ne(t)];return i==je||i!=Ae&&(Ie(e)?De(e):!!e)},Ne=ze.normalize=function(t){return String(t).replace(Be,".").toLowerCase()},Fe=ze.data={},Ae=ze.NATIVE="N",je=ze.POLYFILL="P",Re=ze,Le=xt,He=r,We=g(g.bind),qe=function(t,e){return Le(t),void 0===e?t:He?We(t,e):function(){return t.apply(e,arguments)}},Ve={},Ue=b&&o((function(){return 42!=Object.defineProperty((function(){}),"prototype",{value:42,writable:!1}).prototype})),Ye=n,Xe=Y,Ge=Ye.String,Ke=Ye.TypeError,$e=function(t){if(Xe(t))return t;throw Ke(Ge(t)+" is not an object")},Ze=b,Qe=ke,Je=Ue,ti=$e,ei=ve,ii=n.TypeError,ni=Object.defineProperty,oi=Object.getOwnPropertyDescriptor,ri="enumerable",si="configurable",ai="writable";Ve.f=Ze?Je?function(t,e,i){if(ti(t),e=ei(e),ti(i),"function"==typeof t&&"prototype"===e&&"value"in i&&ai in i&&!i.writable){var n=oi(t,e);n&&n.writable&&(t[e]=i.value,i={configurable:si in i?i.configurable:n.configurable,enumerable:ri in i?i.enumerable:n.enumerable,writable:!1})}return ni(t,e,i)}:ni:function(t,e,i){if(ti(t),e=ei(e),ti(i),Qe)try{return ni(t,e,i)}catch(t){}if("get"in i||"set"in i)throw ii("Accessors not supported");return"value"in i&&(t[e]=i.value),t};var hi=Ve,li=M,di=b?function(t,e,i){return hi.f(t,e,li(1,i))}:function(t,e,i){return t[e]=i,t},ci=n,ui=d,fi=g,pi=y,vi=m.f,gi=Re,yi=X,mi=qe,bi=di,wi=Wt,ki=function(t){var e=function(i,n,o){if(this instanceof e){switch(arguments.length){case 0:return new t;case 1:return new t(i);case 2:return new t(i,n)}return new t(i,n,o)}return ui(t,this,arguments)};return e.prototype=t.prototype,e},_i=function(t,e){var i,n,o,r,s,a,h,l,d=t.target,c=t.global,u=t.stat,f=t.proto,p=c?ci:u?ci[d]:(ci[d]||{}).prototype,v=c?yi:yi[d]||bi(yi,d,{})[d],g=v.prototype;for(o in e)i=!gi(c?o:d+(u?".":"#")+o,t.forced)&&p&&wi(p,o),s=v[o],i&&(a=t.noTargetGet?(l=vi(p,o))&&l.value:p[o]),r=i&&a?a:e[o],i&&typeof s==typeof r||(h=t.bind&&i?mi(r,ci):t.wrap&&i?ki(r):f&&pi(r)?fi(r):r,(t.sham||r&&r.sham||s&&s.sham)&&bi(h,"sham",!0),bi(v,o,h),f&&(wi(yi,n=d+"Prototype")||bi(yi,n,{}),bi(yi[n],o,r),t.real&&g&&!g[o]&&bi(g,o,r)))},xi=Math.ceil,Ei=Math.floor,Oi=function(t){var e=+t;return e!=e||0===e?0:(e>0?Ei:xi)(e)},Ci=Oi,Si=Math.max,Ti=Math.min,Mi=function(t,e){var i=Ci(t);return i<0?Si(i+e,0):Ti(i,e)},Pi=Oi,Di=Math.min,Ii=function(t){return t>0?Di(Pi(t),9007199254740991):0},Bi=function(t){return Ii(t.length)},zi=V,Ni=Mi,Fi=Bi,Ai=function(t){return function(e,i,n){var o,r=zi(e),s=Fi(r),a=Ni(n,s);if(t&&i!=i){for(;s>a;)if((o=r[a++])!=o)return!0}else for(;s>a;a++)if((t||a in r)&&r[a]===i)return t||a||0;return!t&&-1}},ji={includes:Ai(!0),indexOf:Ai(!1)},Ri={},Li=Wt,Hi=V,Wi=ji.indexOf,qi=Ri,Vi=g([].push),Ui=function(t,e){var i,n=Hi(t),o=0,r=[];for(i in n)!Li(qi,i)&&Li(n,i)&&Vi(r,i);for(;e.length>o;)Li(n,i=e[o++])&&(~Wi(r,i)||Vi(r,i));return r},Yi=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],Xi=Ui,Gi=Yi,Ki=Object.keys||function(t){return Xi(t,Gi)},$i={};$i.f=Object.getOwnPropertySymbols;var Zi=b,Qi=g,Ji=_,tn=o,en=Ki,nn=$i,on=x,rn=Rt,sn=R,an=Object.assign,hn=Object.defineProperty,ln=Qi([].concat),dn=!an||tn((function(){if(Zi&&1!==an({b:1},an(hn({},"a",{enumerable:!0,get:function(){hn(this,"b",{value:3,enumerable:!1})}}),{b:2})).b)return!0;var t={},e={},i=Symbol(),n="abcdefghijklmnopqrst";return t[i]=7,n.split("").forEach((function(t){e[t]=t})),7!=an({},t)[i]||en(an({},e)).join("")!=n}))?function(t,e){for(var i=rn(t),n=arguments.length,o=1,r=nn.f,s=on.f;n>o;)for(var a,h=sn(arguments[o++]),l=r?ln(en(h),r(h)):en(h),d=l.length,c=0;d>c;)a=l[c++],Zi&&!Ji(s,h,a)||(i[a]=h[a]);return i}:an,cn=dn;_i({target:"Object",stat:!0,forced:Object.assign!==cn},{assign:cn});var un=X.Object.assign,fn=g([].slice),pn=g,vn=xt,gn=Y,yn=Wt,mn=fn,bn=r,wn=n.Function,kn=pn([].concat),_n=pn([].join),xn={},En=function(t,e,i){if(!yn(xn,e)){for(var n=[],o=0;o=.1;)(p=+r[c++%s])>d&&(p=d),f=Math.sqrt(p*p/(1+l*l)),e+=f=a<0?-f:f,i+=l*f,!0===u?t.lineTo(e,i):t.moveTo(e,i),d-=p,u=!u}var Ln={circle:Nn,dashedLine:Rn,database:jn,diamond:function(t,e,i,n){t.beginPath(),t.lineTo(e,i+n),t.lineTo(e+n,i),t.lineTo(e,i-n),t.lineTo(e-n,i),t.closePath()},ellipse:An,ellipse_vis:An,hexagon:function(t,e,i,n){t.beginPath();var o=2*Math.PI/6;t.moveTo(e+n,i);for(var r=1;r<6;r++)t.lineTo(e+n*Math.cos(o*r),i+n*Math.sin(o*r));t.closePath()},roundRect:Fn,square:function(t,e,i,n){t.beginPath(),t.rect(e-n,i-n,2*n,2*n),t.closePath()},star:function(t,e,i,n){t.beginPath(),i+=.1*(n*=.82);for(var o=0;o<10;o++){var r=o%2==0?1.3*n:.5*n;t.lineTo(e+r*Math.sin(2*o*Math.PI/10),i-r*Math.cos(2*o*Math.PI/10))}t.closePath()},triangle:function(t,e,i,n){t.beginPath(),i+=.275*(n*=1.15);var o=2*n,r=o/2,s=Math.sqrt(3)/6*o,a=Math.sqrt(o*o-r*r);t.moveTo(e,i-(a-s)),t.lineTo(e+r,i+s),t.lineTo(e-r,i+s),t.lineTo(e,i-(a-s)),t.closePath()},triangleDown:function(t,e,i,n){t.beginPath(),i-=.275*(n*=1.15);var o=2*n,r=o/2,s=Math.sqrt(3)/6*o,a=Math.sqrt(o*o-r*r);t.moveTo(e,i+(a-s)),t.lineTo(e+r,i-s),t.lineTo(e-r,i-s),t.lineTo(e,i+(a-s)),t.closePath()}};var Hn={exports:{}};!function(t){function e(t){if(t)return function(t){for(var i in e.prototype)t[i]=e.prototype[i];return t}(t)}t.exports=e,e.prototype.on=e.prototype.addEventListener=function(t,e){return this._callbacks=this._callbacks||{},(this._callbacks["$"+t]=this._callbacks["$"+t]||[]).push(e),this},e.prototype.once=function(t,e){function i(){this.off(t,i),e.apply(this,arguments)}return i.fn=e,this.on(t,i),this},e.prototype.off=e.prototype.removeListener=e.prototype.removeAllListeners=e.prototype.removeEventListener=function(t,e){if(this._callbacks=this._callbacks||{},0==arguments.length)return this._callbacks={},this;var i,n=this._callbacks["$"+t];if(!n)return this;if(1==arguments.length)return delete this._callbacks["$"+t],this;for(var o=0;o=a?t?"":void 0:(n=ao(r,s))<55296||n>56319||s+1===a||(o=ao(r,s+1))<56320||o>57343?t?so(r,s):n:t?ho(r,s,s+2):o-56320+(n-55296<<10)+65536}},co={codeAt:lo(!1),charAt:lo(!0)},uo=y,fo=Nt,po=g(Function.toString);uo(fo.inspectSource)||(fo.inspectSource=function(t){return po(t)});var vo,go,yo,mo=fo.inspectSource,bo=y,wo=mo,ko=n.WeakMap,_o=bo(ko)&&/native code/.test(wo(ko)),xo=Pt.exports,Eo=Xt,Oo=xo("keys"),Co=function(t){return Oo[t]||(Oo[t]=Eo(t))},So=_o,To=n,Mo=g,Po=Y,Do=di,Io=Wt,Bo=Nt,zo=Co,No=Ri,Fo="Object already initialized",Ao=To.TypeError,jo=To.WeakMap;if(So||Bo.state){var Ro=Bo.state||(Bo.state=new jo),Lo=Mo(Ro.get),Ho=Mo(Ro.has),Wo=Mo(Ro.set);vo=function(t,e){if(Ho(Ro,t))throw new Ao(Fo);return e.facade=t,Wo(Ro,t,e),e},go=function(t){return Lo(Ro,t)||{}},yo=function(t){return Ho(Ro,t)}}else{var qo=zo("state");No[qo]=!0,vo=function(t,e){if(Io(t,qo))throw new Ao(Fo);return e.facade=t,Do(t,qo,e),e},go=function(t){return Io(t,qo)?t[qo]:{}},yo=function(t){return Io(t,qo)}}var Vo={set:vo,get:go,has:yo,enforce:function(t){return yo(t)?go(t):vo(t,{})},getterFor:function(t){return function(e){var i;if(!Po(e)||(i=go(e)).type!==t)throw Ao("Incompatible receiver, "+t+" required");return i}}},Uo=b,Yo=Wt,Xo=Function.prototype,Go=Uo&&Object.getOwnPropertyDescriptor,Ko=Yo(Xo,"name"),$o={EXISTS:Ko,PROPER:Ko&&"something"===function(){}.name,CONFIGURABLE:Ko&&(!Uo||Uo&&Go(Xo,"name").configurable)},Zo={},Qo=b,Jo=Ue,tr=Ve,er=$e,ir=V,nr=Ki;Zo.f=Qo&&!Jo?Object.defineProperties:function(t,e){er(t);for(var i,n=ir(e),o=nr(e),r=o.length,s=0;r>s;)tr.f(t,i=o[s++],n[i]);return t};var or,rr=Q("document","documentElement"),sr=$e,ar=Zo,hr=Yi,lr=Ri,dr=rr,cr=be,ur=Co("IE_PROTO"),fr=function(){},pr=function(t){return"