changes
This commit is contained in:
@@ -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);
|
||||
1331
Work in Progress/fusion_quotations/static/src/js/quotation_form.js
Normal file
1331
Work in Progress/fusion_quotations/static/src/js/quotation_form.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user