I can drag and drop items into the canvas

I
This commit is contained in:
2025-07-02 18:23:49 +02:00
parent 7f6a19813d
commit f4e8f7a16c
10 changed files with 555 additions and 12 deletions

View File

@@ -4,7 +4,7 @@ from typing import Any
from fasthtml.components import *
from components.BaseComponent import BaseComponent
from components_helpers import set_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters
from components_helpers import apply_boundaries, mk_dialog_buttons, safe_get_dialog_buttons_parameters
from core.utils import get_unique_id
@@ -108,7 +108,7 @@ class AdminForm(BaseComponent):
for item in self.form_fields
],
mk_dialog_buttons(**safe_get_dialog_buttons_parameters(self._hooks)),
**set_boundaries(self._boundaries),
**apply_boundaries(self._boundaries),
cls="fieldset bg-base-200 border-base-300 rounded-box w-xs border p-4"
)
)

View File

@@ -9,7 +9,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.datagrid_new.settings import DataGridSettings
from components.hoildays.helpers.nibelisparser import NibelisParser
from components.repositories.constants import USERS_REPOSITORY_NAME, HOLIDAYS_TABLE_NAME
from components_helpers import mk_dialog_buttons, set_boundaries
from components_helpers import mk_dialog_buttons, apply_boundaries
from core.instance_manager import InstanceManager
@@ -50,7 +50,7 @@ class ImportHolidays(BaseComponent):
mk_dialog_buttons(ok_title="Import", cls="mt-2", on_ok=self.commands.import_holidays()),
id=self._id,
cls="m-2",
**set_boundaries(self._boundaries, other=26),
**apply_boundaries(self._boundaries, other=26),
)
@staticmethod

View File

@@ -9,7 +9,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
from components.debugger.assets.icons import icon_expanded, icon_collapsed, icon_class
from components.debugger.commands import JsonViewerCommands
from components.debugger.constants import INDENT_SIZE, MAX_TEXT_LENGTH, NODE_OBJECT, NODES_KEYS_TO_NOT_EXPAND
from components_helpers import set_boundaries
from components_helpers import apply_boundaries
from core.serializer import TAG_OBJECT
from core.utils import get_unique_id
@@ -299,7 +299,7 @@ class JsonViewer(BaseComponent):
style="margin-left: 0px;"),
cls="mmt-jsonviewer",
id=f"{self._id}",
**set_boundaries(self._boundaries),
**apply_boundaries(self._boundaries),
)
def __eq__(self, other):

View File

@@ -32,3 +32,33 @@ def post(session, _id: str, name: str, tab_boundaries: str):
f"Entering {Routes.AddWorkflow} with args {debug_session(session)}, {_id=}, {name=}, {tab_boundaries=}")
instance = InstanceManager.get(session, _id)
return instance.show_workflow(name, json.loads(tab_boundaries))
@rt(Routes.AddComponent)
def post(session, _id: str, component_type: str, x: int, y: int):
logger.debug(
f"Entering {Routes.AddComponent} with args {debug_session(session)}, {_id=}, {component_type=}, {x=}, {y=}")
instance = InstanceManager.get(session, _id)
return instance.add_component(component_type, x, y)
@rt(Routes.MoveComponent)
def post(session, _id: str, component_id: str, x: int, y: int):
logger.debug(
f"Entering {Routes.MoveComponent} with args {debug_session(session)}, {_id=}, {component_id=}, {x=}, {y=}")
instance = InstanceManager.get(session, _id)
return instance.move_component(component_id, x, y)
@rt(Routes.DeleteComponent)
def post(session, _id: str, component_id: str):
logger.debug(
f"Entering {Routes.DeleteComponent} with args {debug_session(session)}, {_id=}, {component_id=}")
instance = InstanceManager.get(session, _id)
return instance.delete_component(component_id)
@rt(Routes.AddConnection)
def post(session, _id: str, from_id: str, to_id: str):
logger.debug(
f"Entering {Routes.AddConnection} with args {debug_session(session)}, {_id=}, {from_id=}, {to_id=}")
instance = InstanceManager.get(session, _id)
return instance.add_connection(from_id, to_id)

View File

@@ -0,0 +1,67 @@
.wkf-toolbox-item {
cursor: grab;
}
.wkf-toolbox-item:active {
cursor: grabbing;
}
.wkf-canvas {
position: relative;
min-height: 600px;
background-image:
linear-gradient(rgba(0,0,0,.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,.1) 1px, transparent 1px);
background-size: 20px 20px;
}
.wkf-workflow-component {
position: absolute;
cursor: move;
border: 2px solid transparent;
transition: all 0.2s;
}
.wkf-workflow-component:hover {
border-color: #3b82f6;
transform: scale(1.02);
}
.wkf-workflow-component.selected {
border-color: #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
}
.wkf-connection-line {
position: absolute;
pointer-events: none;
z-index: 1;
}
.wkf-connection-point {
position: absolute;
width: 12px;
height: 12px;
background: #3b82f6;
border-radius: 50%;
cursor: crosshair;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.wkf-output-point {
right: -6px;
top: 50%;
transform: translateY(-50%);
}
.wkf-input-point {
left: -6px;
top: 50%;
transform: translateY(-50%);
}
.wkf-connection-point:hover {
background: #ef4444;
transform: translateY(-50%) scale(1.2);
}

View File

@@ -0,0 +1,226 @@
function bindWorkflowDesigner(elementId) {
// Store state for this specific designer instance
const designer = {
draggedType: null,
draggedComponent: null,
selectedComponent: null,
connectionMode: false,
connectionStart: null
};
// Get the designer container and canvas
const designerContainer = document.getElementById(elementId);
//const canvas = designerContainer.querySelector('.wkf-canvas');
const canvas = document.getElementById("c_" + elementId);
// === Event delegation for components ===
// Use event delegation for all component-related events
designerContainer.addEventListener('dragstart', (event) => {
// Handle toolbox items
if (event.target.closest('.wkf-toolbox-item')) {
designer.draggedType = event.target.closest('.wkf-toolbox-item').dataset.type;
event.dataTransfer.effectAllowed = 'copy';
}
// Handle components
if (event.target.closest('.wkf-workflow-component')) {
designer.draggedComponent = event.target.closest('.wkf-workflow-component').dataset.componentId;
event.dataTransfer.effectAllowed = 'move';
}
});
designerContainer.addEventListener('drag', (event) => {
if (!event.target.closest('.wkf-workflow-component')) return;
if (event.clientX === 0 && event.clientY === 0) return; // Ignore invalid drag events
const component = event.target.closest('.wkf-workflow-component');
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left - 64;
const y = event.clientY - rect.top - 40;
component.style.left = Math.max(0, x) + 'px';
component.style.top = Math.max(0, y) + 'px';
// Update connections in real-time
updateConnections(designerContainer);
});
designerContainer.addEventListener('dragend', (event) => {
if (!event.target.closest('.wkf-workflow-component')) return;
if (designer.draggedComponent) {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left - 64;
const y = event.clientY - rect.top - 40;
htmx.ajax('POST', '/workflows/move-component', {
target: `#c_${elementId}`,
headers: {"Content-Type": "application/x-www-form-urlencoded"},
swap: "innerHTML",
values: {
_id: elementId,
component_id: designer.draggedComponent,
x: Math.max(0, x),
y: Math.max(0, y),
}
});
designer.draggedComponent = null;
// Update connections after drag ends
updateConnections(designerContainer);
}
});
// Handle component clicks using event delegation
designerContainer.addEventListener('click', (event) => {
// Handle canvas clicks to reset connection mode and deselect components
if (event.target === canvas || event.target.classList.contains('wkf-canvas')) {
if (designer.connectionStart) {
designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => {
point.style.background = '#3b82f6';
});
designer.connectionStart = null;
}
// Deselect components
designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => {
comp.classList.remove('selected');
});
designer.selectedComponent = null;
return;
}
// Handle component selection
const component = event.target.closest('.wkf-workflow-component');
if (component) {
event.stopPropagation();
// Remove previous selection
designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => {
comp.classList.remove('selected');
});
// Select current component
component.classList.add('selected');
designer.selectedComponent = component.dataset.componentId;
}
// Handle connection points
const connectionPoint = event.target.closest('.wkf-connection-point');
if (connectionPoint) {
event.stopPropagation();
const componentId = connectionPoint.dataset.componentId;
const pointType = connectionPoint.dataset.pointType;
if (!designer.connectionStart) {
// Start connection from output point
if (pointType === 'output') {
designer.connectionStart = {
componentId: componentId,
pointType: pointType
};
connectionPoint.style.background = '#ef4444';
console.log('Connection started from:', componentId);
}
} else {
// Complete connection to input point
if (pointType === 'input' && componentId !== designer.connectionStart.componentId) {
htmx.ajax('POST', '/workflows/add-connection', {
target: `#c_${elementId}`,
headers: {"Content-Type": "application/x-www-form-urlencoded"},
swap: "innerHTML",
values: {
_id: elementId,
from_id: designer.connectionStart.componentId,
to_id: componentId,
}
});
}
// Reset connection mode
designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => {
point.style.background = '#3b82f6';
});
designer.connectionStart = null;
}
}
});
// Canvas drag over event (for dropping new components)
canvas.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
});
// Canvas drop event (for creating new components)
canvas.addEventListener('drop', (event) => {
event.preventDefault();
if (designer.draggedType) {
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left - 64; // Center the component
const y = event.clientY - rect.top - 40;
htmx.ajax('POST', '/workflows/add-component', {
target: `#c_${elementId}`,
headers: {"Content-Type": "application/x-www-form-urlencoded"},
swap: "innerHTML",
values: {
_id: elementId,
component_type: designer.draggedType,
x: Math.max(0, x),
y: Math.max(0, y),
}
});
designer.draggedType = null;
}
});
// Delete selected component with Delete key
document.addEventListener('keydown', (event) => {
if (event.key === 'Delete' && designer.selectedComponent) {
htmx.ajax('POST', '/workflows/delete-component', {
target: `#c_${elementId}`,
headers: {"Content-Type": "application/x-www-form-urlencoded"},
swap: "innerHTML",
values: {
_id: elementId,
component_id: designer.selectedComponent,
}
});
}
});
// Function to update all connection lines for this designer
function updateConnections(container) {
const connectionLines = container.querySelectorAll('.wkf-connection-line');
connectionLines.forEach(svg => {
const connectionData = svg.dataset;
if (connectionData.fromId && connectionData.toId) {
updateConnectionLine(svg, connectionData.fromId, connectionData.toId, container);
}
});
}
// Function to update a single connection line
function updateConnectionLine(svg, fromId, toId, container) {
const fromComp = container.querySelector(`[data-component-id="${fromId}"]`);
const toComp = container.querySelector(`[data-component-id="${toId}"]`);
if (!fromComp || !toComp) return;
// Get current positions
const fromX = parseInt(fromComp.style.left) + 128; // component width + output point
const fromY = parseInt(fromComp.style.top) + 40; // component height / 2
const toX = parseInt(toComp.style.left);
const toY = parseInt(toComp.style.top) + 40;
// Create curved path
const midX = (fromX + toX) / 2;
const path = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`;
// Update the path
const pathElement = svg.querySelector('path');
if (pathElement) {
pathElement.setAttribute('d', path);
}
}
// Return the designer object for possible external use
return designer;
}
// Initialize all workflow designers on page load
document.addEventListener('DOMContentLoaded', () => {
// Find all workflow designer containers and bind them
document.querySelectorAll('[id^="wkf-designer-"]').forEach(designer => {
bindDesigner(designer.id);
});
});

View File

@@ -1,10 +1,36 @@
from fastcore.basics import NotStr
from fasthtml.components import *
from fasthtml.xtend import Script
from components.BaseComponent import BaseComponent
from components.workflows.constants import WORKFLOW_DESIGNER_INSTANCE_ID
from components.workflows.db_management import WorkflowsDesignerSettings
from components.workflows.db_management import WorkflowsDesignerSettings, WorkflowsDesignerState, WorkflowComponent, \
Connection
from components_helpers import apply_boundaries, mk_tooltip
from core.utils import get_unique_id
# Component templates
COMPONENT_TYPES = {
"producer": {
"title": "Data Producer",
"description": "Generates or loads data",
"icon": "📊",
"color": "bg-green-100 border-green-300"
},
"filter": {
"title": "Data Filter",
"description": "Filters and transforms data",
"icon": "🔍",
"color": "bg-blue-100 border-blue-300"
},
"presenter": {
"title": "Data Presenter",
"description": "Displays or exports data",
"icon": "📋",
"color": "bg-purple-100 border-purple-300"
}
}
class WorkflowDesigner(BaseComponent):
def __init__(self, session,
@@ -15,13 +41,71 @@ class WorkflowDesigner(BaseComponent):
super().__init__(session, _id)
self._settings_manager = settings_manager
self._designer_settings = designer_settings
self.boundaries = boundaries
self._state = WorkflowsDesignerState()
self._boundaries = boundaries
def set_boundaries(self, boundaries: dict):
self.boundaries = boundaries
self._boundaries = boundaries
def refresh(self, oob=False):
return self._mk_elements()
def add_component(self, component_type, x, y):
self._state.component_counter += 1
component_id = f"comp_{self._state.component_counter}"
info = COMPONENT_TYPES[component_type]
component = WorkflowComponent(
id=component_id,
type=component_type,
x=int(x),
y=int(y),
title=info["title"],
description=info["description"]
)
self._state.components[component_id] = component
return self.refresh()
def move_component(self, component_id, x, y):
if component_id in self._state.components:
self._state.components[component_id].x = int(x)
self._state.components[component_id].y = int(y)
return self.refresh()
def delete_component(self, component_id):
# Remove component
if component_id in self._state.components:
del self._state.components[component_id]
# Remove related connections
self._state.connections = [connection for connection in self._state.connections
if connection.from_id != component_id and connection.to_id != component_id]
return self.refresh()
def add_connection(self, from_id, to_id):
# Check if connection already exists
for connection in self._state.connections:
if connection.from_id == from_id and connection.to_id == to_id:
return self.refresh() # , self.error_message("Connection already exists")
connection_id = f"conn_{len(self._state.connections) + 1}"
connection = Connection(id=connection_id, from_id=from_id, to_id=to_id)
self._state.connections.append(connection)
return self.refresh()
def __ft__(self):
return Div(f"Workflow Designer - {self._designer_settings.workflow_name}")
return Div(
H1(f"{self._designer_settings.workflow_name}", cls="text-xl font-bold"),
P("Drag components from the toolbox to the canvas to create your workflow.", cls="text-sm mb-6"),
self._mk_designer(),
Script(f"bindWorkflowDesigner('{self._id}');"),
**apply_boundaries(self._boundaries),
id=f"{self._id}",
)
@staticmethod
def create_component_id(session, suffix=None):
@@ -30,3 +114,114 @@ class WorkflowDesigner(BaseComponent):
suffix = get_unique_id()
return f"{prefix}{suffix}"
@staticmethod
def _mk_toolbox_item(component_type: str, info: dict):
return Div(
mk_tooltip(
Div(
Span(info["icon"], cls="text-2xl mb-2"),
H4(info["title"], cls="font-semibold text-sm"),
cls=f"p-3 rounded-lg border-2 {info['color']} text-center"
),
tooltip=info["description"]),
cls="wkf-toolbox-item p-2",
draggable="true",
data_type=component_type
)
@staticmethod
def _mk_workflow_component(component: WorkflowComponent):
info = COMPONENT_TYPES[component.type]
return Div(
# Input connection point
Div(cls="wkf-connection-point wkf-input-point",
data_component_id=component.id,
data_point_type="input"),
# Component content
Div(
Span(info["icon"], cls="text-xl mb-1"),
H4(component.title, cls="font-semibold text-sm"),
P(info["description"], cls="text-xs text-gray-600"),
cls=f"p-3 rounded-lg border-2 {info['color']} bg-white shadow-lg"
),
# Output connection point
Div(cls="wkf-connection-point wkf-output-point",
data_component_id=component.id,
data_point_type="output"),
cls="wkf-workflow-component w-32",
style=f"left: {component.x}px; top: {component.y}px;",
data_component_id=component.id,
draggable="true"
)
def _mk_connection_svg(self, conn: Connection):
if conn.from_id not in self._state.components or conn.to_id not in self._state.components:
return ""
from_comp = self._state.components[conn.from_id]
to_comp = self._state.components[conn.to_id]
# Calculate connection points (approximate)
x1 = from_comp.x + 128 # component width + output point
y1 = from_comp.y + 40 # component height / 2
x2 = to_comp.x
y2 = to_comp.y + 40
# Create curved path
mid_x = (x1 + x2) / 2
path = f"M {x1} {y1} C {mid_x} {y1}, {mid_x} {y2}, {x2} {y2}"
return f"""
<svg class="wkf-connection-line" style="left: 0; top: 0; width: 100%; height: 100%;"
data-from-id="{conn.from_id}" data-to-id="{conn.to_id}">
<path d="{path}" stroke="#3b82f6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6"/>
</marker>
</defs>
</svg>
"""
def _mk_elements(self):
return Div(
# Render connections
*[NotStr(self._mk_connection_svg(conn)) for conn in self._state.connections],
# Render components
*[self._mk_workflow_component(comp) for comp in self._state.components.values()],
cls="wkf-canvas bg-white rounded-lg border relative",
),
def _mk_canvas(self, oob=False):
return Div(
self._mk_elements(),
cls="flex-1 ",
id=f"c_{self._id}",
hx_swap_oob='true' if oob else None,
),
def _mk_toolbox(self):
return Div(
H2("Toolbox", cls="text-lg font-semibold mb-4 text-gray-700"),
Div(
*[self._mk_toolbox_item(comp_type, info)
for comp_type, info in COMPONENT_TYPES.items()],
cls="space-y-3"
),
cls="w-32 p-4 bg-gray-50 rounded-lg border"
)
def _mk_designer(self):
return Div(
self._mk_toolbox(), # (Left side)
self._mk_canvas(), # (Right side)
cls="flex gap-4",
id=f"d_{self._id}",
)

View File

@@ -8,3 +8,7 @@ class Routes:
AddWorkflow = "/add-workflow"
SelectWorkflow = "/select-workflow"
ShowWorkflow = "/show-workflow"
AddComponent = "/add-component"
MoveComponent = "/move-component"
DeleteComponent = "/delete-component"
AddConnection = "/add-connection"

View File

@@ -6,11 +6,32 @@ from core.settings_management import SettingsManager
logger = logging.getLogger("WorkflowsSettings")
# Data structures
@dataclass
class WorkflowComponent:
id: str
type: str
x: int
y: int
title: str
description: str
@dataclass
class Connection:
id: str
from_id: str
to_id: str
@dataclass
class WorkflowsDesignerSettings:
workflow_name: str
@dataclass
class WorkflowsDesignerState:
components: dict[str, WorkflowComponent] = field(default_factory=dict)
connections: list[Connection] = field(default_factory=list)
component_counter = 0
@dataclass
class WorkflowsSettings:

View File

@@ -81,7 +81,7 @@ def mk_accordion_section(component_id, title, icon, content, selected=False):
)
def set_boundaries(boundaries, remove_margin=True, other=0):
def apply_boundaries(boundaries, remove_margin=True, other=0):
if isinstance(boundaries, int):
max_height = boundaries
else: