This commit is contained in:
gsinghpal
2026-03-13 12:38:28 -04:00
parent db4b9aa278
commit fc3c966484
2975 changed files with 1614 additions and 498 deletions

View File

@@ -0,0 +1,705 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component, useState, onWillStart, onMounted, onWillUnmount, useRef } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";
/* ──────────────────────────────────────────────────────────────────
Constants
────────────────────────────────────────────────────────────────── */
const NODE_TYPES = {
start: { label: 'Start', icon: 'fa-play', color: '#10b981', width: 140, height: 50 },
end: { label: 'End', icon: 'fa-stop', color: '#ef4444', width: 140, height: 50 },
decision: { label: 'Decision', icon: 'fa-code-fork', color: '#f59e0b', width: 180, height: 80 },
option_group: { label: 'Option Group', icon: 'fa-list-ul', color: '#3b82f6', width: 200, height: 80 },
product_select: { label: 'Product Selection', icon: 'fa-shopping-cart', color: '#8b5cf6', width: 200, height: 80 },
measurement_check: { label: 'Measurement Check', icon: 'fa-ruler', color: '#f97316', width: 200, height: 80 },
action: { label: 'Action', icon: 'fa-bolt', color: '#14b8a6', width: 180, height: 70 },
};
const GRID_SIZE = 20;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 3;
const PORT_RADIUS = 7;
/* ──────────────────────────────────────────────────────────────────
Utility helpers
────────────────────────────────────────────────────────────────── */
function bezierPath(x1, y1, x2, y2) {
const dx = Math.abs(x2 - x1) * 0.5;
return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
}
function getPortsForNode(node) {
const meta = NODE_TYPES[node.node_type] || NODE_TYPES.action;
const w = meta.width;
const h = meta.height;
const ports = [];
// Input port (all except start)
if (node.node_type !== 'start') {
ports.push({ key: 'in', type: 'input', x: 0, y: h / 2 });
}
// Output ports
if (node.node_type === 'decision') {
ports.push({ key: 'true', type: 'output', x: w, y: h * 0.33, label: 'Yes' });
ports.push({ key: 'false', type: 'output', x: w, y: h * 0.67, label: 'No' });
} else if (node.node_type === 'measurement_check') {
ports.push({ key: 'pass', type: 'output', x: w, y: h * 0.33, label: 'Pass' });
ports.push({ key: 'fail', type: 'output', x: w, y: h * 0.67, label: 'Fail' });
} else if (node.node_type === 'option_group' && node.node_options && node.node_options.length) {
const count = node.node_options.length;
node.node_options.forEach((opt, i) => {
ports.push({
key: opt.port_key || `opt_${opt.sequence || (i * 10 + 10)}`,
type: 'output',
x: w,
y: h * ((i + 1) / (count + 1)),
label: opt.name,
});
});
} else if (node.node_type !== 'end') {
ports.push({ key: 'out', type: 'output', x: w, y: h / 2 });
}
return ports;
}
function snapToGrid(val) {
return Math.round(val / GRID_SIZE) * GRID_SIZE;
}
/* ══════════════════════════════════════════════════════════════════
FlowDesignerAction — main OWL client action
══════════════════════════════════════════════════════════════════ */
export class FlowDesignerAction extends Component {
static template = "fusion_quotations.FlowDesignerAction";
static props = { "*": true };
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
this.notification = useService("notification");
this.svgRef = useRef("svgCanvas");
this.canvasGroupRef = useRef("canvasGroup");
this.flowId = this.props.action?.context?.active_id || false;
this.state = useState({
flowName: '',
equipmentType: '',
nodes: [],
connections: [],
selectedNodeId: null,
selectedConnectionId: null,
dirty: false,
saving: false,
loading: true,
panelOpen: false,
// Viewport
viewX: 0,
viewY: 0,
zoom: 1,
});
// Interaction state (non-reactive, doesn't need re-render)
this._dragging = null; // { nodeId, startX, startY, origX, origY }
this._panning = null; // { startX, startY, origVX, origVY }
this._connecting = null; // { sourceNodeId, sourcePort, tempX, tempY }
this._tempLine = null; // SVG path element for connection preview
onWillStart(async () => {
if (this.flowId) {
await this._loadGraph();
}
});
onMounted(() => {
this._bindCanvasEvents();
});
onWillUnmount(() => {
this._unbindCanvasEvents();
});
}
/* ──────────────────────────────────────────────────────────────
Data loading / saving
────────────────────────────────────────────────────────────── */
async _loadGraph() {
this.state.loading = true;
try {
const graph = await this.orm.call(
'fusion.wc.config.flow',
'load_flow_graph',
[this.flowId]
);
this.state.flowName = graph.name || '';
this.state.equipmentType = graph.equipment_type || '';
this.state.nodes = graph.nodes || [];
this.state.connections = graph.connections || [];
if (graph.canvas) {
this.state.viewX = graph.canvas.x || 0;
this.state.viewY = graph.canvas.y || 0;
this.state.zoom = graph.canvas.zoom || 1;
}
} catch (e) {
this.notification.add(_t("Error loading flow: ") + e.message, { type: "danger" });
console.error("Load flow error:", e);
}
this.state.loading = false;
}
async _saveGraph() {
if (!this.flowId) return;
this.state.saving = true;
try {
await this.orm.call(
'fusion.wc.config.flow',
'save_flow_graph',
[this.flowId, {
canvas: { x: this.state.viewX, y: this.state.viewY, zoom: this.state.zoom },
nodes: this.state.nodes,
connections: this.state.connections,
}]
);
this.state.dirty = false;
this.notification.add(_t("Flow saved successfully"), { type: "success" });
// Reload to get real IDs from server
await this._loadGraph();
} catch (e) {
this.notification.add(_t("Error saving flow: ") + e.message, { type: "danger" });
console.error("Save flow error:", e);
}
this.state.saving = false;
}
/* ──────────────────────────────────────────────────────────────
Coordinate transforms
────────────────────────────────────────────────────────────── */
screenToCanvas(sx, sy) {
const svg = this.svgRef.el;
if (!svg) return { x: sx, y: sy };
const rect = svg.getBoundingClientRect();
return {
x: (sx - rect.left - this.state.viewX) / this.state.zoom,
y: (sy - rect.top - this.state.viewY) / this.state.zoom,
};
}
/* ──────────────────────────────────────────────────────────────
Computed data for template
────────────────────────────────────────────────────────────── */
get transformStr() {
return `translate(${this.state.viewX}, ${this.state.viewY}) scale(${this.state.zoom})`;
}
get nodesWithPorts() {
return this.state.nodes.map(n => {
const meta = NODE_TYPES[n.node_type] || NODE_TYPES.action;
return {
...n,
width: meta.width,
height: meta.height,
meta,
ports: getPortsForNode(n),
isSelected: n.id === this.state.selectedNodeId,
};
});
}
get connectionPaths() {
const nodeMap = {};
for (const n of this.state.nodes) {
const meta = NODE_TYPES[n.node_type] || NODE_TYPES.action;
nodeMap[n.id] = { ...n, width: meta.width, height: meta.height, ports: getPortsForNode(n) };
}
return this.state.connections.map(c => {
const src = nodeMap[c.source_node_id];
const tgt = nodeMap[c.target_node_id];
if (!src || !tgt) return null;
const srcPort = src.ports.find(p => p.key === c.source_port) || src.ports.find(p => p.type === 'output');
const tgtPort = tgt.ports.find(p => p.type === 'input') || { x: 0, y: tgt.height / 2 };
if (!srcPort) return null;
const x1 = src.pos_x + srcPort.x;
const y1 = src.pos_y + srcPort.y;
const x2 = tgt.pos_x + tgtPort.x;
const y2 = tgt.pos_y + tgtPort.y;
const label = c.label || '';
const labelW = Math.max(label.length * 7 + 16, 32); // approx width from char count
return {
...c,
path: bezierPath(x1, y1, x2, y2),
isSelected: c.id === this.state.selectedConnectionId,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2,
labelW,
};
}).filter(Boolean);
}
getNodeTypeLabel(nodeType) {
return (NODE_TYPES[nodeType] || {}).label || nodeType;
}
/* ──────────────────────────────────────────────────────────────
Toolbar / Palette actions
────────────────────────────────────────────────────────────── */
onAddNode(ev) {
const nodeType = ev.currentTarget.dataset.nodeType;
if (!nodeType) return;
const meta = NODE_TYPES[nodeType];
if (!meta) return;
// Place in center of current viewport
const svg = this.svgRef.el;
const rect = svg ? svg.getBoundingClientRect() : { width: 800, height: 600 };
const pos = this.screenToCanvas(rect.width / 2, rect.height / 2);
const tempId = 'new_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5);
const newNode = {
id: tempId,
name: meta.label,
node_type: nodeType,
pos_x: snapToGrid(pos.x - meta.width / 2),
pos_y: snapToGrid(pos.y - meta.height / 2),
color: meta.color,
icon: meta.icon,
section_id: false,
section_name: '',
decision_field: '',
decision_operator: '',
decision_value: '',
measurement_field: '',
comparison: '',
threshold_value: 0,
action_type: '',
target_option_ids: [],
target_step: 0,
config_json: '{}',
node_options: [],
};
this.state.nodes.push(newNode);
this.state.selectedNodeId = tempId;
this.state.selectedConnectionId = null;
this.state.panelOpen = true;
this.state.dirty = true;
}
onDeleteSelected() {
if (this.state.selectedConnectionId) {
this.state.connections = this.state.connections.filter(
c => c.id !== this.state.selectedConnectionId
);
this.state.selectedConnectionId = null;
this.state.dirty = true;
}
if (this.state.selectedNodeId) {
const nodeId = this.state.selectedNodeId;
this.state.nodes = this.state.nodes.filter(n => n.id !== nodeId);
this.state.connections = this.state.connections.filter(
c => c.source_node_id !== nodeId && c.target_node_id !== nodeId
);
this.state.selectedNodeId = null;
this.state.panelOpen = false;
this.state.dirty = true;
}
}
onSave() {
this._saveGraph();
}
onZoomIn() {
this.state.zoom = Math.min(MAX_ZOOM, this.state.zoom * 1.2);
}
onZoomOut() {
this.state.zoom = Math.max(MIN_ZOOM, this.state.zoom / 1.2);
}
onZoomReset() {
this.state.zoom = 1;
this.state.viewX = 0;
this.state.viewY = 0;
}
onBack() {
// Navigate back to the flow form / list.
// history.back() is the most reliable way to return to the
// previous Odoo view that opened this client action.
window.history.back();
}
/* ──────────────────────────────────────────────────────────────
Canvas events
────────────────────────────────────────────────────────────── */
_bindCanvasEvents() {
const svg = this.svgRef.el;
if (!svg) return;
this._onMouseDown = this._handleMouseDown.bind(this);
this._onMouseMove = this._handleMouseMove.bind(this);
this._onMouseUp = this._handleMouseUp.bind(this);
this._onWheel = this._handleWheel.bind(this);
this._onKeyDown = this._handleKeyDown.bind(this);
svg.addEventListener('mousedown', this._onMouseDown);
window.addEventListener('mousemove', this._onMouseMove);
window.addEventListener('mouseup', this._onMouseUp);
svg.addEventListener('wheel', this._onWheel, { passive: false });
window.addEventListener('keydown', this._onKeyDown);
}
_unbindCanvasEvents() {
const svg = this.svgRef.el;
if (svg) {
svg.removeEventListener('mousedown', this._onMouseDown);
svg.removeEventListener('wheel', this._onWheel);
}
window.removeEventListener('mousemove', this._onMouseMove);
window.removeEventListener('mouseup', this._onMouseUp);
window.removeEventListener('keydown', this._onKeyDown);
}
/* ──────────────────────────────────────────────────────────────
Port interaction — pure coordinate math.
No DOM queries (elementsFromPoint / ev.target) — works
entirely from component state + getPortsForNode().
────────────────────────────────────────────────────────────── */
/**
* Find the nearest port within hit radius using coordinate math.
* @param {number} cx — x in canvas coordinate space
* @param {number} cy — y in canvas coordinate space
* @param {'input'|'output'} portType
* @returns {{ node: Object, port: Object, x: number, y: number }|null}
*/
_findPortNear(cx, cy, portType) {
const HIT_R = 20; // canvas-unit hit radius (~20px at zoom 1)
const rSq = HIT_R * HIT_R;
let best = null;
let bestDist = Infinity;
for (const node of this.state.nodes) {
const ports = getPortsForNode(node);
for (const port of ports) {
if (port.type !== portType) continue;
const px = node.pos_x + port.x;
const py = node.pos_y + port.y;
const dx = cx - px;
const dy = cy - py;
const distSq = dx * dx + dy * dy;
if (distSq <= rSq && distSq < bestDist) {
bestDist = distSq;
best = { node, port, x: px, y: py };
}
}
}
return best;
}
_handleMouseDown(ev) {
if (ev.button !== 0 && ev.button !== 1) return;
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
// ── 1. Port detection via coordinate math ──
// No DOM queries — purely checks distance to port centres.
if (ev.button === 0) {
const outHit = this._findPortNear(canvasPos.x, canvasPos.y, 'output');
if (outHit) {
this._connecting = {
sourceNodeId: outHit.node.id,
sourcePort: outHit.port.key,
startX: outHit.x,
startY: outHit.y,
};
// Create temp bezier for visual feedback
const svgNs = 'http://www.w3.org/2000/svg';
this._tempLine = document.createElementNS(svgNs, 'path');
this._tempLine.classList.add('fd-connection-temp');
this._tempLine.setAttribute('stroke-width', '2');
this._tempLine.setAttribute('pointer-events', 'none');
const group = this.canvasGroupRef.el;
if (group) group.appendChild(this._tempLine);
ev.preventDefault();
ev.stopPropagation();
return;
}
// Input port click — block drag/pan, do nothing
const inHit = this._findPortNear(canvasPos.x, canvasPos.y, 'input');
if (inHit) {
ev.stopPropagation();
return;
}
}
// If already connecting, ignore other interactions
if (this._connecting) return;
// ── 2. Node drag ──
const target = ev.target;
const nodeGroup = target.closest('.fd-node-group');
if (nodeGroup && ev.button === 0) {
const nodeId = nodeGroup.dataset.nodeId;
const nid = isNaN(nodeId) ? nodeId : parseInt(nodeId);
const node = this.state.nodes.find(n => n.id === nid || String(n.id) === nodeId);
if (node) {
this.state.selectedNodeId = node.id;
this.state.selectedConnectionId = null;
this.state.panelOpen = true;
this._dragging = {
nodeId: node.id,
startX: ev.clientX,
startY: ev.clientY,
origX: node.pos_x,
origY: node.pos_y,
};
ev.stopPropagation();
return;
}
}
// ── 3. Connection click ──
if (target.classList.contains('fd-connection-path') || target.classList.contains('fd-connection-hit')) {
const connId = target.dataset.connId;
if (connId) {
const cid = isNaN(connId) ? connId : parseInt(connId);
this.state.selectedConnectionId = cid;
this.state.selectedNodeId = null;
this.state.panelOpen = false;
ev.stopPropagation();
return;
}
}
// ── 4. Canvas pan ──
if (ev.button === 1 || (ev.button === 0 && !nodeGroup)) {
this.state.selectedNodeId = null;
this.state.selectedConnectionId = null;
this.state.panelOpen = false;
this._panning = {
startX: ev.clientX,
startY: ev.clientY,
origVX: this.state.viewX,
origVY: this.state.viewY,
};
ev.preventDefault();
}
}
_handleMouseMove(ev) {
if (this._dragging) {
const dx = (ev.clientX - this._dragging.startX) / this.state.zoom;
const dy = (ev.clientY - this._dragging.startY) / this.state.zoom;
const node = this.state.nodes.find(n => n.id === this._dragging.nodeId);
if (node) {
node.pos_x = snapToGrid(this._dragging.origX + dx);
node.pos_y = snapToGrid(this._dragging.origY + dy);
}
return;
}
if (this._panning) {
this.state.viewX = this._panning.origVX + (ev.clientX - this._panning.startX);
this.state.viewY = this._panning.origVY + (ev.clientY - this._panning.startY);
return;
}
if (this._connecting && this._tempLine) {
const pos = this.screenToCanvas(ev.clientX, ev.clientY);
this._tempLine.setAttribute('d',
bezierPath(this._connecting.startX, this._connecting.startY, pos.x, pos.y));
return;
}
// Port hover cursor — crosshair when near any port
const svg = this.svgRef.el;
if (svg) {
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
const hit = this._findPortNear(canvasPos.x, canvasPos.y, 'output')
|| this._findPortNear(canvasPos.x, canvasPos.y, 'input');
svg.style.cursor = hit ? 'crosshair' : '';
}
}
_handleMouseUp(ev) {
if (this._dragging) {
this.state.dirty = true;
this._dragging = null;
return;
}
if (this._panning) {
this._panning = null;
return;
}
if (this._connecting) {
const canvasPos = this.screenToCanvas(ev.clientX, ev.clientY);
const inHit = this._findPortNear(canvasPos.x, canvasPos.y, 'input');
if (inHit) {
const srcId = this._connecting.sourceNodeId;
const tgtId = inHit.node.id;
// Prevent self-connection
if (String(tgtId) !== String(srcId)) {
// Check for duplicate
const exists = this.state.connections.some(
c => c.source_node_id === srcId &&
c.target_node_id === tgtId &&
c.source_port === this._connecting.sourcePort
);
if (!exists) {
this.state.connections.push({
id: 'new_conn_' + Date.now(),
source_node_id: srcId,
target_node_id: tgtId,
source_port: this._connecting.sourcePort,
label: '',
condition_json: '{}',
sequence: 10,
});
this.state.dirty = true;
}
}
}
// Clean up temp line
if (this._tempLine && this._tempLine.parentNode) {
this._tempLine.parentNode.removeChild(this._tempLine);
}
this._tempLine = null;
this._connecting = null;
}
}
_handleWheel(ev) {
ev.preventDefault();
const delta = ev.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.state.zoom * delta));
// Zoom toward cursor position
const svg = this.svgRef.el;
const rect = svg.getBoundingClientRect();
const mx = ev.clientX - rect.left;
const my = ev.clientY - rect.top;
const scale = newZoom / this.state.zoom;
this.state.viewX = mx - (mx - this.state.viewX) * scale;
this.state.viewY = my - (my - this.state.viewY) * scale;
this.state.zoom = newZoom;
}
_handleKeyDown(ev) {
if (ev.key === 'Delete' || ev.key === 'Backspace') {
// Only if not typing in an input
if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'TEXTAREA' || ev.target.tagName === 'SELECT') return;
this.onDeleteSelected();
ev.preventDefault();
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault();
this.onSave();
}
}
/* ──────────────────────────────────────────────────────────────
Properties panel
────────────────────────────────────────────────────────────── */
get selectedNode() {
if (!this.state.selectedNodeId) return null;
return this.state.nodes.find(n => n.id === this.state.selectedNodeId) || null;
}
onPanelClose() {
this.state.panelOpen = false;
this.state.selectedNodeId = null;
}
onNodeFieldChange(ev) {
const node = this.selectedNode;
if (!node) return;
const field = ev.currentTarget.dataset.field;
const value = ev.currentTarget.value;
if (field && node.hasOwnProperty(field)) {
node[field] = value;
this.state.dirty = true;
}
}
onNodeNumberChange(ev) {
const node = this.selectedNode;
if (!node) return;
const field = ev.currentTarget.dataset.field;
const value = parseFloat(ev.currentTarget.value) || 0;
if (field) {
node[field] = value;
this.state.dirty = true;
}
}
/* ──────────────────────────────────────────────────────────────
Node option management (option_group)
────────────────────────────────────────────────────────────── */
onAddNodeOption() {
const node = this.selectedNode;
if (!node) return;
if (!node.node_options) node.node_options = [];
const seq = (node.node_options.length + 1) * 10;
node.node_options.push({
id: 'new_opt_' + Date.now(),
name: 'Option ' + (node.node_options.length + 1),
sequence: seq,
section_option_id: false,
enables_option_ids: [],
disables_option_ids: [],
requires_option_ids: [],
port_key: 'opt_' + seq,
});
this.state.dirty = true;
}
onRemoveNodeOption(ev) {
const node = this.selectedNode;
if (!node || !node.node_options) return;
const idx = parseInt(ev.currentTarget.dataset.index);
if (!isNaN(idx) && idx >= 0 && idx < node.node_options.length) {
const removed = node.node_options.splice(idx, 1)[0];
// Also remove connections using this port
if (removed) {
this.state.connections = this.state.connections.filter(
c => !(c.source_node_id === node.id && c.source_port === removed.port_key)
);
}
this.state.dirty = true;
}
}
onNodeOptionNameChange(ev) {
const node = this.selectedNode;
if (!node || !node.node_options) return;
const idx = parseInt(ev.currentTarget.dataset.index);
if (!isNaN(idx) && idx >= 0 && idx < node.node_options.length) {
node.node_options[idx].name = ev.currentTarget.value;
this.state.dirty = true;
}
}
}
registry.category("actions").add("fusion_flow_designer", FlowDesignerAction);

File diff suppressed because it is too large Load Diff